Automerge Migration Plan

Date: 2026-01-06 (Updated: 2026-01-06)

This document provides a step-by-step implementation plan for migrating Minuta from plain JSON storage to Automerge-based CRDT storage.

Executive Summary

Goal: Migrate storage from JSON to Automerge binary format for conflict-free multi-device sync.

Approach: Hybrid document model (tags in one document, records per month)

Risk Level: Medium - data migration with backward compatibility

Estimated Scope: ~1500 lines of new code, ~500 lines of tests


Table of Contents

  1. Prerequisites
  2. Phase 1: Add Automerge Dependency
  3. Phase 2: Create Automerge Utilities
  4. Phase 3: Create Automerge Types
  5. Phase 4: Implement AutomergeStorageService
  6. Phase 5: Create Migration Service
  7. Phase 6: Implement Conflict Resolution
  8. Phase 7: Integration & Dual-Read Support
  9. Phase 8: Update All LocalFileStorageService Usages
  10. Phase 9: Testing
  11. Phase 10: Rollout Strategy
  12. Phase 11: Post-Migration Cleanup
  13. Rollback Plan
  14. Success Criteria

1. Prerequisites

1.1 Validation Tasks

  • Verify automerge-swift 0.6.1 works with Swift 5.9+ and iOS 17+
  • Confirm Mac Catalyst support
  • Check binary size impact (~2MB expected)
  • Create minimal proof-of-concept testing Document CRUD and merge()

1.2 Key Decisions

DecisionChoiceRationale
Document modelHybrid (tags in one doc, records per month)Balances granularity and sync efficiency
Image storageKeep separate files (unchanged)Binary data shouldn’t be in CRDT
Comment fieldSimple string (LWW)Character-level CRDT is overkill for single-user
Timestamp formatMilliseconds (Int64)Matches Automerge.Timestamp

2. Phase 1: Add Automerge Dependency

2.1 Update Package.swift

// Shared/Package.swift
import PackageDescription

let package = Package(
    name: "MinutaShared",
    platforms: [
        .iOS(.v17),
        .macOS(.v14)
    ],
    products: [
        .library(name: "MinutaShared", targets: ["MinutaShared"]),
    ],
    dependencies: [
        .package(url: "https://github.com/automerge/automerge-swift.git", from: "0.6.1")
    ],
    targets: [
        .target(
            name: "MinutaShared",
            dependencies: [
                .product(name: "Automerge", package: "automerge-swift")
            ],
            path: "Sources/MinutaShared"),
        .testTarget(
            name: "MinutaSharedTests",
            dependencies: ["MinutaShared"],
            path: "Tests/MinutaSharedTests"),
    ]
)

2.2 Verification

cd Shared && swift build && swift test

2.3 Acceptance Criteria

  • Package resolves without errors
  • import Automerge compiles
  • All existing tests pass

3. Phase 2: Create Automerge Utilities

Create Shared/Sources/MinutaShared/Automerge/AutomergeUtilities.swift:

import Automerge
import Foundation

// MARK: - Date Conversion

extension Date {
    /// Create Date from Automerge timestamp (milliseconds since epoch)
    init(automergeTimestamp ms: Int64) {
        self.init(timeIntervalSince1970: Double(ms) / 1000.0)
    }

    /// Convert to Automerge timestamp (milliseconds since epoch)
    var automergeTimestamp: Int64 {
        Int64(timeIntervalSince1970 * 1000)
    }
}

// MARK: - Document Helpers

enum AutomergeDocument {
    /// Load a document from binary data
    static func load(from data: Data) throws -> Document {
        do {
            return try Document(data)
        } catch {
            throw AutomergeError.deserializationFailed(underlying: error)
        }
    }

    /// Merge two documents, returning the merged result
    static func merge(_ primary: Document, with other: Document) throws {
        do {
            try primary.merge(other: other)
        } catch {
            throw AutomergeError.mergeFailed(underlying: error)
        }
    }
}

// MARK: - Property Accessors

/// Helper for reading/writing Automerge document properties
struct AutomergeProperty<T> {
    let document: Document
    let objectId: ObjId
    let key: String

    init(_ document: Document, _ objectId: ObjId, _ key: String) {
        self.document = document
        self.objectId = objectId
        self.key = key
    }
}

extension AutomergeProperty where T == String {
    func get(default defaultValue: String = "") -> String {
        guard case .Scalar(.String(let value)) = try? document.get(obj: objectId, key: key) else {
            return defaultValue
        }
        return value
    }

    func set(_ value: String) throws {
        try document.put(obj: objectId, key: key, value: .String(value))
    }
}

extension AutomergeProperty where T == Bool {
    func get(default defaultValue: Bool = false) -> Bool {
        guard case .Scalar(.Boolean(let value)) = try? document.get(obj: objectId, key: key) else {
            return defaultValue
        }
        return value
    }

    func set(_ value: Bool) throws {
        try document.put(obj: objectId, key: key, value: .Boolean(value))
    }
}

extension AutomergeProperty where T == Date {
    func get(default defaultValue: Date = Date()) -> Date {
        guard case .Scalar(.Timestamp(let value)) = try? document.get(obj: objectId, key: key) else {
            return defaultValue
        }
        return Date(automergeTimestamp: value)
    }

    func set(_ value: Date) throws {
        try document.put(obj: objectId, key: key, value: .Timestamp(value.automergeTimestamp))
    }
}

extension AutomergeProperty where T == Date? {
    func get() -> Date? {
        guard case .Scalar(.Timestamp(let value)) = try? document.get(obj: objectId, key: key) else {
            return nil
        }
        return Date(automergeTimestamp: value)
    }

    func set(_ value: Date?) throws {
        if let date = value {
            try document.put(obj: objectId, key: key, value: .Timestamp(date.automergeTimestamp))
        } else {
            try document.delete(obj: objectId, key: key)
        }
    }
}

extension AutomergeProperty where T == UUID? {
    func get() -> UUID? {
        guard case .Scalar(.String(let value)) = try? document.get(obj: objectId, key: key) else {
            return nil
        }
        return UUID(uuidString: value)
    }

    func set(_ value: UUID?) throws {
        if let id = value {
            try document.put(obj: objectId, key: key, value: .String(id.uuidString))
        } else {
            try document.delete(obj: objectId, key: key)
        }
    }
}

extension AutomergeProperty where T == String? {
    func get() -> String? {
        guard case .Scalar(.String(let value)) = try? document.get(obj: objectId, key: key) else {
            return nil
        }
        return value
    }

    func set(_ value: String?) throws {
        if let text = value {
            try document.put(obj: objectId, key: key, value: .String(text))
        } else {
            try document.delete(obj: objectId, key: key)
        }
    }
}

extension AutomergeProperty where T == [String] {
    func get() -> [String] {
        guard case .Object(let listId, .List) = try? document.get(obj: objectId, key: key) else {
            return []
        }
        let length = (try? document.length(obj: listId)) ?? 0
        var result: [String] = []
        for i in 0..<length {
            if case .Scalar(.String(let value)) = try? document.get(obj: listId, index: i) {
                result.append(value)
            }
        }
        return result
    }

    func set(_ value: [String]) throws {
        try? document.delete(obj: objectId, key: key)
        let listId = try document.putObject(obj: objectId, key: key, ty: .List)
        for (i, item) in value.enumerated() {
            try document.insert(obj: listId, index: UInt64(i), value: .String(item))
        }
    }
}

// MARK: - Errors

public enum AutomergeError: Error, LocalizedError {
    case documentCorrupted(details: String)
    case objectNotFound(key: String)
    case deserializationFailed(underlying: Error)
    case mergeFailed(underlying: Error)
    case migrationFailed(details: String)

    public var errorDescription: String? {
        switch self {
        case .documentCorrupted(let details):
            return "Document corrupted: \(details)"
        case .objectNotFound(let key):
            return "Object not found: \(key)"
        case .deserializationFailed(let error):
            return "Deserialization failed: \(error.localizedDescription)"
        case .mergeFailed(let error):
            return "Merge failed: \(error.localizedDescription)"
        case .migrationFailed(let details):
            return "Migration failed: \(details)"
        }
    }
}

3.1 Acceptance Criteria

  • Date conversion utilities work correctly
  • Property accessors reduce boilerplate
  • Error types follow existing FileStorageError pattern

4. Phase 3: Create Automerge Types

4.1 AutomergeTag.swift

import Automerge
import Foundation

/// Wrapper to read/write Tag from Automerge document
public struct AutomergeTag {
    private let document: Document
    private let objectId: ObjId
    public let tagId: UUID

    public init(document: Document, objectId: ObjId, tagId: UUID) {
        self.document = document
        self.objectId = objectId
        self.tagId = tagId
    }

    // MARK: - Properties (using AutomergeProperty helper)

    public var name: String {
        AutomergeProperty<String>(document, objectId, "name").get()
    }

    public func setName(_ value: String) throws {
        try AutomergeProperty<String>(document, objectId, "name").set(value)
    }

    public var color: String {
        AutomergeProperty<String>(document, objectId, "color").get(default: "#808080")
    }

    public func setColor(_ value: String) throws {
        try AutomergeProperty<String>(document, objectId, "color").set(value)
    }

    public var createdAt: Date {
        AutomergeProperty<Date>(document, objectId, "createdAt").get()
    }

    public func setCreatedAt(_ value: Date) throws {
        try AutomergeProperty<Date>(document, objectId, "createdAt").set(value)
    }

    public var isArchived: Bool {
        AutomergeProperty<Bool>(document, objectId, "isArchived").get()
    }

    public func setIsArchived(_ value: Bool) throws {
        try AutomergeProperty<Bool>(document, objectId, "isArchived").set(value)
    }

    /// Convert to plain Tag for UI
    public func asTag() -> Tag {
        Tag(id: tagId, name: name, color: color, createdAt: createdAt, isArchived: isArchived)
    }

    /// Create a new tag in the document
    public static func create(in document: Document, at parent: ObjId, tag: Tag) throws -> AutomergeTag {
        let objId = try document.putObject(obj: parent, key: tag.id.uuidString, ty: .Map)
        let wrapper = AutomergeTag(document: document, objectId: objId, tagId: tag.id)
        try wrapper.setName(tag.name)
        try wrapper.setColor(tag.color)
        try wrapper.setCreatedAt(tag.createdAt)
        try wrapper.setIsArchived(tag.isArchived)
        return wrapper
    }
}

4.2 AutomergeTimeRecord.swift

import Automerge
import Foundation

/// Wrapper to read/write TimeRecord from Automerge document
public struct AutomergeTimeRecord {
    private let document: Document
    private let objectId: ObjId
    public let recordId: UUID

    public init(document: Document, objectId: ObjId, recordId: UUID) {
        self.document = document
        self.objectId = objectId
        self.recordId = recordId
    }

    // MARK: - Properties

    public var startTime: Date {
        AutomergeProperty<Date>(document, objectId, "startTime").get()
    }

    public func setStartTime(_ value: Date) throws {
        try AutomergeProperty<Date>(document, objectId, "startTime").set(value)
    }

    public var endTime: Date? {
        AutomergeProperty<Date?>(document, objectId, "endTime").get()
    }

    public func setEndTime(_ value: Date?) throws {
        try AutomergeProperty<Date?>(document, objectId, "endTime").set(value)
    }

    public var tagId: UUID? {
        AutomergeProperty<UUID?>(document, objectId, "tagId").get()
    }

    public func setTagId(_ value: UUID?) throws {
        try AutomergeProperty<UUID?>(document, objectId, "tagId").set(value)
    }

    public var comment: String? {
        AutomergeProperty<String?>(document, objectId, "comment").get()
    }

    public func setComment(_ value: String?) throws {
        try AutomergeProperty<String?>(document, objectId, "comment").set(value)
    }

    public var images: [String] {
        AutomergeProperty<[String]>(document, objectId, "images").get()
    }

    public func setImages(_ value: [String]) throws {
        try AutomergeProperty<[String]>(document, objectId, "images").set(value)
    }

    /// Convert to plain TimeRecord for UI
    public func asTimeRecord() -> TimeRecord {
        TimeRecord(
            id: recordId,
            startTime: startTime,
            endTime: endTime,
            tagId: tagId,
            comment: comment,
            images: images
        )
    }

    /// Create a new record in the document
    public static func create(in document: Document, at parent: ObjId, record: TimeRecord) throws -> AutomergeTimeRecord {
        let objId = try document.putObject(obj: parent, key: record.id.uuidString, ty: .Map)
        let wrapper = AutomergeTimeRecord(document: document, objectId: objId, recordId: record.id)
        try wrapper.setStartTime(record.startTime)
        try wrapper.setEndTime(record.endTime)
        try wrapper.setTagId(record.tagId)
        try wrapper.setComment(record.comment)
        try wrapper.setImages(record.images)
        return wrapper
    }
}

4.3 Acceptance Criteria

  • Wrapper types use AutomergeProperty for reduced boilerplate
  • Round-trip (create -> save -> load -> read) works for both types
  • Merge of concurrent edits works correctly

5. Phase 4: Implement AutomergeStorageService

Create Shared/Sources/MinutaShared/Services/AutomergeStorageService.swift:

import Automerge
import Foundation

/// Storage service using Automerge for CRDT sync
public actor AutomergeStorageService: FileStorageServiceProtocol {
    private let baseURL: URL
    private let fileManager: FileManager

    private var tagsDocument: Document?
    private var recordDocuments: [String: Document] = [:]
    private let maxCachedDocuments = 12

    public var storageURL: URL { baseURL }

    public init(storageURL: URL? = nil) {
        self.fileManager = FileManager.default
        if let storageURL = storageURL {
            self.baseURL = storageURL
        } else {
            let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
            self.baseURL = documents.appendingPathComponent("Minuta")
        }
    }

    // MARK: - File Paths

    private var tagsFileURL: URL { baseURL.appendingPathComponent("tags.automerge") }
    private var recordsDirectoryURL: URL { baseURL.appendingPathComponent("records") }

    private func recordDocumentURL(year: Int, month: Int) -> URL {
        recordsDirectoryURL
            .appendingPathComponent(String(year))
            .appendingPathComponent(String(format: "%02d.automerge", month))
    }

    private func ensureDirectoryExists(at url: URL) throws {
        if !fileManager.fileExists(atPath: url.path) {
            try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
        }
    }

    // MARK: - Tags

    private func loadTagsDocument() throws -> Document {
        if let cached = tagsDocument { return cached }

        let url = tagsFileURL
        let doc: Document
        if fileManager.fileExists(atPath: url.path) {
            let data = try Data(contentsOf: url)
            doc = try AutomergeDocument.load(from: data)
        } else {
            doc = Document()
        }
        tagsDocument = doc
        return doc
    }

    private func saveTagsDocument() throws {
        guard let doc = tagsDocument else { return }
        try ensureDirectoryExists(at: baseURL)
        try doc.save().write(to: tagsFileURL, options: .atomic)
    }

    public func loadTags() async throws -> [Tag] {
        let doc = try loadTagsDocument()
        var tags: [Tag] = []
        for key in try doc.keys(obj: .ROOT) {
            guard let id = UUID(uuidString: key),
                  case .Object(let objId, .Map) = try doc.get(obj: .ROOT, key: key) else { continue }
            tags.append(AutomergeTag(document: doc, objectId: objId, tagId: id).asTag())
        }
        AppLogger.storage.info("loadTags: \(tags.count) tags from automerge")
        return tags
    }

    public func saveTags(_ tags: [Tag]) async throws {
        let doc = try loadTagsDocument()
        let existingKeys = Set(try doc.keys(obj: .ROOT))
        let newKeys = Set(tags.map { $0.id.uuidString })

        for key in existingKeys where !newKeys.contains(key) {
            try doc.delete(obj: .ROOT, key: key)
        }

        for tag in tags {
            let key = tag.id.uuidString
            if case .Object(let objId, .Map) = try? doc.get(obj: .ROOT, key: key) {
                let wrapper = AutomergeTag(document: doc, objectId: objId, tagId: tag.id)
                try wrapper.setName(tag.name)
                try wrapper.setColor(tag.color)
                try wrapper.setIsArchived(tag.isArchived)
            } else {
                _ = try AutomergeTag.create(in: doc, at: .ROOT, tag: tag)
            }
        }
        try saveTagsDocument()
        AppLogger.storage.info("saveTags: \(tags.count) tags")
    }

    // MARK: - Records

    private func loadRecordDocument(year: Int, month: Int) throws -> Document {
        let key = String(format: "%04d-%02d", year, month)
        if let cached = recordDocuments[key] { return cached }

        evictOldDocumentsIfNeeded()

        let url = recordDocumentURL(year: year, month: month)
        let doc: Document
        if fileManager.fileExists(atPath: url.path) {
            let data = try Data(contentsOf: url)
            doc = try AutomergeDocument.load(from: data)
        } else {
            doc = Document()
        }
        recordDocuments[key] = doc
        return doc
    }

    private func saveRecordDocument(year: Int, month: Int) throws {
        let key = String(format: "%04d-%02d", year, month)
        guard let doc = recordDocuments[key] else { return }

        let yearDir = recordsDirectoryURL.appendingPathComponent(String(year))
        try ensureDirectoryExists(at: yearDir)
        try doc.save().write(to: recordDocumentURL(year: year, month: month), options: .atomic)
    }

    private func evictOldDocumentsIfNeeded() {
        if recordDocuments.count >= maxCachedDocuments {
            let sorted = recordDocuments.keys.sorted()
            for key in sorted.prefix(recordDocuments.count - maxCachedDocuments + 1) {
                recordDocuments.removeValue(forKey: key)
            }
        }
    }

    public func loadRecords(from startDate: Date, to endDate: Date) async throws -> [TimeRecord] {
        var records: [TimeRecord] = []
        for (year, month) in monthsBetween(start: startDate, end: endDate) {
            let doc = try loadRecordDocument(year: year, month: month)
            for key in try doc.keys(obj: .ROOT) {
                guard let id = UUID(uuidString: key),
                      case .Object(let objId, .Map) = try doc.get(obj: .ROOT, key: key) else { continue }
                let record = AutomergeTimeRecord(document: doc, objectId: objId, recordId: id).asTimeRecord()
                if record.startTime >= startDate && record.startTime <= endDate {
                    records.append(record)
                }
            }
        }
        return records.sorted { $0.startTime > $1.startTime }
    }

    public func loadRunningRecords() async throws -> [TimeRecord] {
        try await loadAllRecords().filter { $0.isRunning }
    }

    public func loadAllRecords() async throws -> [TimeRecord] {
        var records: [TimeRecord] = []
        guard fileManager.fileExists(atPath: recordsDirectoryURL.path),
              let yearDirs = try? fileManager.contentsOfDirectory(at: recordsDirectoryURL, includingPropertiesForKeys: nil) else {
            return []
        }

        for yearDir in yearDirs where yearDir.hasDirectoryPath {
            guard let year = Int(yearDir.lastPathComponent),
                  let monthFiles = try? fileManager.contentsOfDirectory(at: yearDir, includingPropertiesForKeys: nil) else { continue }

            for monthFile in monthFiles where monthFile.pathExtension == "automerge" {
                guard let month = Int(monthFile.deletingPathExtension().lastPathComponent) else { continue }
                let doc = try loadRecordDocument(year: year, month: month)
                for key in try doc.keys(obj: .ROOT) {
                    guard let id = UUID(uuidString: key),
                          case .Object(let objId, .Map) = try doc.get(obj: .ROOT, key: key) else { continue }
                    records.append(AutomergeTimeRecord(document: doc, objectId: objId, recordId: id).asTimeRecord())
                }
            }
        }
        return records.sorted { $0.startTime > $1.startTime }
    }

    public func saveRecord(_ record: TimeRecord) async throws {
        let (year, month) = yearMonth(from: record.startTime)
        let doc = try loadRecordDocument(year: year, month: month)
        let key = record.id.uuidString

        if case .Object(let objId, .Map) = try? doc.get(obj: .ROOT, key: key) {
            let wrapper = AutomergeTimeRecord(document: doc, objectId: objId, recordId: record.id)
            try wrapper.setStartTime(record.startTime)
            try wrapper.setEndTime(record.endTime)
            try wrapper.setTagId(record.tagId)
            try wrapper.setComment(record.comment)
            try wrapper.setImages(record.images)
        } else {
            _ = try AutomergeTimeRecord.create(in: doc, at: .ROOT, record: record)
        }
        try saveRecordDocument(year: year, month: month)
        AppLogger.storage.info("saveRecord: \(record.id)")
    }

    public func updateRecord(_ record: TimeRecord) async throws {
        try await saveRecord(record)
    }

    public func deleteRecord(_ record: TimeRecord) async throws {
        for imageFilename in record.images {
            try await deleteImage(filename: imageFilename, for: record)
        }
        let (year, month) = yearMonth(from: record.startTime)
        let doc = try loadRecordDocument(year: year, month: month)
        try doc.delete(obj: .ROOT, key: record.id.uuidString)
        try saveRecordDocument(year: year, month: month)
        AppLogger.storage.info("deleteRecord: \(record.id)")
    }

    // MARK: - Images (unchanged from LocalFileStorageService)

    private func recordDirectoryURL(for record: TimeRecord) -> URL {
        let components = record.directoryComponents
        return recordsDirectoryURL.appendingPathComponent(components[0]).appendingPathComponent(components[1])
    }

    public func saveImage(_ data: Data, for record: TimeRecord, filename: String) async throws {
        let directory = recordDirectoryURL(for: record)
        try ensureDirectoryExists(at: directory)
        try data.write(to: directory.appendingPathComponent(filename), options: .atomic)
    }

    public func loadImage(filename: String, for record: TimeRecord) async throws -> Data {
        let fileURL = imageURL(filename: filename, for: record)
        guard fileManager.fileExists(atPath: fileURL.path) else {
            throw FileStorageError.imageNotFound(filename)
        }
        return try Data(contentsOf: fileURL)
    }

    public func deleteImage(filename: String, for record: TimeRecord) async throws {
        let fileURL = imageURL(filename: filename, for: record)
        if fileManager.fileExists(atPath: fileURL.path) {
            try fileManager.removeItem(at: fileURL)
        }
    }

    public nonisolated func imageURL(filename: String, for record: TimeRecord) -> URL {
        let components = record.directoryComponents
        return baseURL.appendingPathComponent("records")
            .appendingPathComponent(components[0])
            .appendingPathComponent(components[1])
            .appendingPathComponent(filename)
    }

    // MARK: - Cache

    public func invalidateCache() {
        tagsDocument = nil
        recordDocuments.removeAll()
        AppLogger.storage.info("invalidateCache: automerge cache cleared")
    }

    // MARK: - Helpers

    private func yearMonth(from date: Date) -> (year: Int, month: Int) {
        let calendar = Calendar.current
        return (calendar.component(.year, from: date), calendar.component(.month, from: date))
    }

    private func monthsBetween(start: Date, end: Date) -> [(year: Int, month: Int)] {
        var result: [(Int, Int)] = []
        var current = start
        let calendar = Calendar.current
        while current <= end {
            result.append(yearMonth(from: current))
            guard let next = calendar.date(byAdding: .month, value: 1, to: current) else { break }
            current = next
        }
        return result
    }
}

5.1 Acceptance Criteria

  • Implements FileStorageServiceProtocol completely
  • LRU cache eviction prevents unbounded memory growth
  • All CRUD operations work correctly
  • Images stored in same structure as LocalFileStorageService

6. Phase 5: Create Migration Service

Create Shared/Sources/MinutaShared/Services/MigrationService.swift:

import Foundation

/// Migrates data from JSON to Automerge format
public actor MigrationService {
    private let legacy: LocalFileStorageService
    private let automerge: AutomergeStorageService
    private let storageURL: URL

    private let migrationCompleteKey = "automerge_migration_complete_v1"

    public struct MigrationResult: Sendable {
        public let tagsCount: Int
        public let recordsCount: Int
        public let imagesCount: Int
        public let errors: [String]
        public let durationSeconds: TimeInterval
        public var isComplete: Bool { errors.isEmpty }
    }

    public init(storageURL: URL) {
        self.storageURL = storageURL
        self.legacy = LocalFileStorageService(baseURL: storageURL)
        self.automerge = AutomergeStorageService(storageURL: storageURL)
    }

    public var needsMigration: Bool {
        get async {
            let legacyTagsFile = storageURL.appendingPathComponent("tags.json")
            let hasLegacyData = FileManager.default.fileExists(atPath: legacyTagsFile.path)
            if !hasLegacyData { return false }
            return !UserDefaults.standard.bool(forKey: migrationCompleteKey)
        }
    }

    public func migrate(progressHandler: ((String) -> Void)? = nil) async throws -> MigrationResult {
        let startTime = Date()
        var errors: [String] = []

        progressHandler?("Creating backup...")
        try await createBackup()

        progressHandler?("Migrating tags...")
        let tagsCount = try await migrateTags(errors: &errors)

        progressHandler?("Migrating records...")
        let (recordsCount, imagesCount) = try await migrateRecords(errors: &errors, progressHandler: progressHandler)

        if errors.isEmpty {
            UserDefaults.standard.set(true, forKey: migrationCompleteKey)
            progressHandler?("Migration complete!")
        } else {
            progressHandler?("Migration completed with \(errors.count) errors")
        }

        let duration = Date().timeIntervalSince(startTime)
        AppLogger.storage.info("Migration: \(tagsCount) tags, \(recordsCount) records, \(imagesCount) images in \(duration)s")

        return MigrationResult(
            tagsCount: tagsCount,
            recordsCount: recordsCount,
            imagesCount: imagesCount,
            errors: errors,
            durationSeconds: duration
        )
    }

    private func createBackup() async throws {
        let timestamp = ISO8601DateFormatter().string(from: Date())
        let backupDir = storageURL.deletingLastPathComponent().appendingPathComponent("Minuta-backup-\(timestamp)")
        if FileManager.default.fileExists(atPath: storageURL.path) {
            try FileManager.default.copyItem(at: storageURL, to: backupDir)
            AppLogger.storage.info("Backup created at \(backupDir.path)")
        }
    }

    private func migrateTags(errors: inout [String]) async throws -> Int {
        do {
            let tags = try await legacy.loadTags()
            if tags.isEmpty { return 0 }
            try await automerge.saveTags(tags)
            return tags.count
        } catch {
            errors.append("Tags: \(error.localizedDescription)")
            return 0
        }
    }

    private func migrateRecords(errors: inout [String], progressHandler: ((String) -> Void)?) async throws -> (records: Int, images: Int) {
        let allRecords: [TimeRecord]
        do {
            allRecords = try await legacy.loadAllRecords()
        } catch {
            errors.append("Records load: \(error.localizedDescription)")
            return (0, 0)
        }

        var migratedRecords = 0
        var migratedImages = 0

        for (index, record) in allRecords.enumerated() {
            if index % 50 == 0 {
                progressHandler?("Migrating record \(index + 1) of \(allRecords.count)...")
            }
            do {
                try await automerge.saveRecord(record)
                migratedRecords += 1
                migratedImages += record.images.count
            } catch {
                errors.append("Record \(record.id): \(error.localizedDescription)")
            }
        }
        return (migratedRecords, migratedImages)
    }

    public func verifyMigration() async throws -> Bool {
        let legacyTags = try await legacy.loadTags()
        let legacyRecords = try await legacy.loadAllRecords()
        let automergeTags = try await automerge.loadTags()
        let automergeRecords = try await automerge.loadAllRecords()
        return legacyTags.count == automergeTags.count && legacyRecords.count == automergeRecords.count
    }

    public func cleanupLegacyData() async throws {
        let legacyTagsFile = storageURL.appendingPathComponent("tags.json")
        if FileManager.default.fileExists(atPath: legacyTagsFile.path) {
            try FileManager.default.removeItem(at: legacyTagsFile)
        }
        try await cleanupJSONFiles(in: storageURL.appendingPathComponent("records"))
        AppLogger.storage.info("Legacy JSON cleanup complete")
    }

    private func cleanupJSONFiles(in directory: URL) async throws {
        guard let contents = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
        for item in contents {
            if item.hasDirectoryPath {
                try await cleanupJSONFiles(in: item)
            } else if item.pathExtension == "json" {
                try FileManager.default.removeItem(at: item)
            }
        }
    }

    /// Reset migration state (for support/debugging)
    public static func resetMigrationState() {
        UserDefaults.standard.removeObject(forKey: "automerge_migration_complete_v1")
    }
}

6.1 Acceptance Criteria

  • Creates backup before migration
  • Reports progress for large datasets
  • Verification compares counts
  • Cleanup removes only JSON files, keeps images

7. Phase 6: Implement Conflict Resolution

Create Shared/Sources/MinutaShared/Services/ConflictResolutionService.swift:

import Automerge
import Foundation

/// Resolves conflict files created by folder sync services (Dropbox, iCloud, Google Drive)
public actor ConflictResolutionService {
    private let storageURL: URL
    private let fileManager = FileManager.default

    /// Cloud-specific conflict file patterns
    private let conflictPatterns = [
        #"\s*\(conflicted copy[^)]*\)"#,  // Dropbox
        #"\s*\(\d+\)"#,                    // Google Drive
        #"\s+\d+(?=\.automerge$)"#,        // iCloud numeric suffix
        #"\s*\(conflict[^)]*\)"#           // Generic
    ]

    public struct ResolutionResult: Sendable {
        public let filesProcessed: Int
        public let conflictsResolved: Int
        public let errors: [String]
    }

    public init(storageURL: URL) {
        self.storageURL = storageURL
    }

    /// Scan for and resolve all conflict files
    public func resolveConflicts() async throws -> ResolutionResult {
        var filesProcessed = 0
        var conflictsResolved = 0
        var errors: [String] = []

        let automergeFiles = try findAutomergeFiles(in: storageURL)
        filesProcessed = automergeFiles.count

        let groups = groupConflictFiles(automergeFiles)

        for (_, files) in groups where files.count > 1 {
            do {
                try await mergeConflictFiles(files)
                conflictsResolved += files.count - 1
            } catch {
                errors.append("Merge failed: \(error.localizedDescription)")
            }
        }

        AppLogger.storage.info("ConflictResolution: \(conflictsResolved) conflicts resolved")
        return ResolutionResult(filesProcessed: filesProcessed, conflictsResolved: conflictsResolved, errors: errors)
    }

    private func findAutomergeFiles(in directory: URL) throws -> [URL] {
        var result: [URL] = []
        guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else {
            return []
        }
        for case let fileURL as URL in enumerator where fileURL.pathExtension == "automerge" {
            result.append(fileURL)
        }
        return result
    }

    private func groupConflictFiles(_ files: [URL]) -> [String: [URL]] {
        var groups: [String: [URL]] = [:]
        for file in files {
            let directory = file.deletingLastPathComponent()
            let baseName = extractBaseName(from: file.lastPathComponent)
            let groupKey = directory.appendingPathComponent(baseName).path
            groups[groupKey, default: []].append(file)
        }
        return groups
    }

    private func extractBaseName(from filename: String) -> String {
        var result = filename.replacingOccurrences(of: ".automerge", with: "")
        for pattern in conflictPatterns {
            result = result.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
        }
        return result.trimmingCharacters(in: .whitespaces) + ".automerge"
    }

    private func isConflictFile(_ url: URL) -> Bool {
        url.lastPathComponent != extractBaseName(from: url.lastPathComponent)
    }

    private func mergeConflictFiles(_ files: [URL]) async throws {
        let primaryURL = files.first { !isConflictFile($0) } ?? files[0]
        var primaryDoc = try Document(Data(contentsOf: primaryURL))

        for conflictURL in files where conflictURL != primaryURL {
            let conflictDoc = try Document(Data(contentsOf: conflictURL))
            try primaryDoc.merge(other: conflictDoc)
            try fileManager.removeItem(at: conflictURL)
            AppLogger.storage.info("Merged conflict: \(conflictURL.lastPathComponent)")
        }

        try primaryDoc.save().write(to: primaryURL, options: .atomic)
    }
}

7.1 Acceptance Criteria

  • Detects Dropbox, iCloud, Google Drive conflict patterns
  • Merges all versions into primary document
  • Deletes conflict files after successful merge
  • Logs all conflict resolutions

8. Phase 7: Integration & Dual-Read Support

Create Shared/Sources/MinutaShared/Services/HybridStorageService.swift:

import Foundation

/// Storage service that reads from both JSON and Automerge during transition
public actor HybridStorageService: FileStorageServiceProtocol {
    private let legacy: LocalFileStorageService
    private let automerge: AutomergeStorageService
    private let migration: MigrationService
    private var migrationComplete: Bool = false

    public var storageURL: URL { automerge.storageURL }

    public init(storageURL: URL) {
        self.legacy = LocalFileStorageService(baseURL: storageURL)
        self.automerge = AutomergeStorageService(storageURL: storageURL)
        self.migration = MigrationService(storageURL: storageURL)
    }

    /// Check and perform migration if needed
    public func checkAndMigrate(progressHandler: ((String) -> Void)? = nil) async throws {
        if await migration.needsMigration {
            AppLogger.storage.info("HybridStorage: starting migration")
            let result = try await migration.migrate(progressHandler: progressHandler)
            migrationComplete = result.isComplete
        } else {
            migrationComplete = true
        }
    }

    // MARK: - Tags

    public func loadTags() async throws -> [Tag] {
        if migrationComplete {
            return try await automerge.loadTags()
        }
        let automergeTags = try await automerge.loadTags()
        return automergeTags.isEmpty ? try await legacy.loadTags() : automergeTags
    }

    public func saveTags(_ tags: [Tag]) async throws {
        try await automerge.saveTags(tags)
    }

    // MARK: - Records

    public func loadRecords(from startDate: Date, to endDate: Date) async throws -> [TimeRecord] {
        if migrationComplete {
            return try await automerge.loadRecords(from: startDate, to: endDate)
        }
        return try await mergeRecords(
            try await automerge.loadRecords(from: startDate, to: endDate),
            try await legacy.loadRecords(from: startDate, to: endDate)
        )
    }

    public func loadRunningRecords() async throws -> [TimeRecord] {
        if migrationComplete {
            return try await automerge.loadRunningRecords()
        }
        return try await mergeRecords(
            try await automerge.loadRunningRecords(),
            try await legacy.loadRunningRecords()
        )
    }

    public func loadAllRecords() async throws -> [TimeRecord] {
        if migrationComplete {
            return try await automerge.loadAllRecords()
        }
        return try await mergeRecords(
            try await automerge.loadAllRecords(),
            try await legacy.loadAllRecords()
        )
    }

    private func mergeRecords(_ automergeRecords: [TimeRecord], _ legacyRecords: [TimeRecord]) -> [TimeRecord] {
        let automergeIds = Set(automergeRecords.map(\.id))
        let uniqueLegacy = legacyRecords.filter { !automergeIds.contains($0.id) }
        return (automergeRecords + uniqueLegacy).sorted { $0.startTime > $1.startTime }
    }

    public func saveRecord(_ record: TimeRecord) async throws {
        try await automerge.saveRecord(record)
    }

    public func updateRecord(_ record: TimeRecord) async throws {
        try await automerge.updateRecord(record)
    }

    public func deleteRecord(_ record: TimeRecord) async throws {
        try await automerge.deleteRecord(record)
        try? await legacy.deleteRecord(record)
    }

    // MARK: - Images

    public func saveImage(_ data: Data, for record: TimeRecord, filename: String) async throws {
        try await automerge.saveImage(data, for: record, filename: filename)
    }

    public func loadImage(filename: String, for record: TimeRecord) async throws -> Data {
        do {
            return try await automerge.loadImage(filename: filename, for: record)
        } catch {
            return try await legacy.loadImage(filename: filename, for: record)
        }
    }

    public func deleteImage(filename: String, for record: TimeRecord) async throws {
        try await automerge.deleteImage(filename: filename, for: record)
    }

    public nonisolated func imageURL(filename: String, for record: TimeRecord) -> URL {
        automerge.imageURL(filename: filename, for: record)
    }

    // MARK: - Cache

    public func invalidateCache() {
        Task {
            await automerge.invalidateCache()
            await legacy.invalidateCache()
        }
    }
}

8.1 Acceptance Criteria

  • Seamlessly handles both formats during transition
  • Migration runs automatically on first access
  • Always writes to Automerge
  • Falls back to legacy for reads during transition

9. Phase 8: Update All LocalFileStorageService Usages

9.1 Files Requiring Updates

FileCurrent UsageChange Required
Minuta/Sources/MinutaApp.swiftLocalFileStorageService in AppStateUse HybridStorageService
Minuta/Sources/AppIntents.swiftLocalFileStorageService directlyUse HybridStorageService

9.2 MinutaApp.swift Changes

Update AppState to use protocol-based storage:

// In AppState class
@Observable
@MainActor
class AppState {
    private(set) var storage: FileStorageServiceProtocol  // Changed from LocalFileStorageService
    private(set) var trackingService: TimeTrackingService
    // ... rest unchanged

    init(storageLocationManager: StorageLocationManager) {
        self.storageLocationManager = storageLocationManager
        let url = storageLocationManager.storageURL
        self.storage = HybridStorageService(storageURL: url)  // Changed
        self.trackingService = TimeTrackingService(storage: storage)
        // ...
    }

    func reinitializeStorage() {
        let url = storageLocationManager.storageURL
        self.storage = HybridStorageService(storageURL: url)  // Changed
        self.trackingService = TimeTrackingService(storage: storage)
        Task { await loadData() }
    }

    func loadData() async {
        // Add migration check at start of loadData
        if let hybrid = storage as? HybridStorageService {
            try? await hybrid.checkAndMigrate()
        }
        // ... rest unchanged
    }
}

9.3 AppIntents.swift Changes

struct StartTimerIntent: AppIntent {
    @MainActor
    func perform() async throws -> some IntentResult & ReturnsValue<String> {
        let storageManager = StorageLocationManager.shared
        let storage = HybridStorageService(storageURL: storageManager.storageURL)  // Changed
        if let hybrid = storage as? HybridStorageService {
            try? await hybrid.checkAndMigrate()
        }
        let trackingService = TimeTrackingService(storage: storage)
        // ... rest unchanged
    }
}

struct StopTimerIntent: AppIntent {
    @MainActor
    func perform() async throws -> some IntentResult & ReturnsValue<String> {
        let storageManager = StorageLocationManager.shared
        let storage = HybridStorageService(storageURL: storageManager.storageURL)  // Changed
        if let hybrid = storage as? HybridStorageService {
            try? await hybrid.checkAndMigrate()
        }
        let trackingService = TimeTrackingService(storage: storage)
        // ... rest unchanged
    }
}

9.4 Acceptance Criteria

  • All direct LocalFileStorageService usages replaced
  • AppIntents work with HybridStorageService
  • Migration triggers automatically on first use

10. Phase 9: Testing

10.1 Unit Tests

Create Shared/Tests/MinutaSharedTests/AutomergeTests.swift:

import XCTest
import Automerge
@testable import MinutaShared

final class AutomergeUtilitiesTests: XCTestCase {
    func testDateConversion() {
        let date = Date()
        let timestamp = date.automergeTimestamp
        let converted = Date(automergeTimestamp: timestamp)
        XCTAssertEqual(date.timeIntervalSince1970, converted.timeIntervalSince1970, accuracy: 0.001)
    }

    func testDateConversionPreservesPrecision() {
        let date = Date(timeIntervalSince1970: 1704067200.123)
        let roundTrip = Date(automergeTimestamp: date.automergeTimestamp)
        XCTAssertEqual(date.timeIntervalSince1970, roundTrip.timeIntervalSince1970, accuracy: 0.001)
    }
}

final class AutomergeTagTests: XCTestCase {
    func testTagRoundTrip() throws {
        let doc = Document()
        let tag = Tag(id: UUID(), name: "Work", color: "#4A90D9", isArchived: false)

        let wrapper = try AutomergeTag.create(in: doc, at: .ROOT, tag: tag)
        let result = wrapper.asTag()

        XCTAssertEqual(result.id, tag.id)
        XCTAssertEqual(result.name, tag.name)
        XCTAssertEqual(result.color, tag.color)
        XCTAssertEqual(result.isArchived, tag.isArchived)
    }

    func testTagMerge() throws {
        let doc1 = Document()
        let tag = Tag(id: UUID(), name: "Work", color: "#4A90D9")
        _ = try AutomergeTag.create(in: doc1, at: .ROOT, tag: tag)

        let doc2 = doc1.fork()

        // Edit name in doc1
        if case .Object(let objId, .Map) = try doc1.get(obj: .ROOT, key: tag.id.uuidString) {
            try AutomergeTag(document: doc1, objectId: objId, tagId: tag.id).setName("Personal")
        }

        // Edit color in doc2
        if case .Object(let objId, .Map) = try doc2.get(obj: .ROOT, key: tag.id.uuidString) {
            try AutomergeTag(document: doc2, objectId: objId, tagId: tag.id).setColor("#E57373")
        }

        try doc1.merge(other: doc2)

        if case .Object(let objId, .Map) = try doc1.get(obj: .ROOT, key: tag.id.uuidString) {
            let merged = AutomergeTag(document: doc1, objectId: objId, tagId: tag.id)
            XCTAssertEqual(merged.name, "Personal")
            XCTAssertEqual(merged.color, "#E57373")
        } else {
            XCTFail("Tag not found")
        }
    }

    func testTagWithUnicode() throws {
        let doc = Document()
        let tag = Tag(id: UUID(), name: "Meeting notes", color: "#4A90D9")
        let wrapper = try AutomergeTag.create(in: doc, at: .ROOT, tag: tag)
        XCTAssertEqual(wrapper.name, "Meeting notes")
    }
}

final class AutomergeTimeRecordTests: XCTestCase {
    func testRecordRoundTrip() throws {
        let doc = Document()
        let record = TimeRecord(
            id: UUID(),
            startTime: Date(),
            endTime: Date().addingTimeInterval(3600),
            tagId: UUID(),
            comment: "Test",
            images: ["a.jpg", "b.jpg"]
        )

        let wrapper = try AutomergeTimeRecord.create(in: doc, at: .ROOT, record: record)
        let result = wrapper.asTimeRecord()

        XCTAssertEqual(result.id, record.id)
        XCTAssertEqual(result.tagId, record.tagId)
        XCTAssertEqual(result.comment, record.comment)
        XCTAssertEqual(result.images, record.images)
    }

    func testRunningTimer() throws {
        let doc = Document()
        let record = TimeRecord(id: UUID(), startTime: Date(), endTime: nil)
        let wrapper = try AutomergeTimeRecord.create(in: doc, at: .ROOT, record: record)
        XCTAssertNil(wrapper.endTime)
        XCTAssertTrue(wrapper.asTimeRecord().isRunning)
    }

    func testConcurrentImageAdd() throws {
        let doc1 = Document()
        let record = TimeRecord(id: UUID(), startTime: Date(), images: [])
        _ = try AutomergeTimeRecord.create(in: doc1, at: .ROOT, record: record)

        let doc2 = doc1.fork()

        if case .Object(let objId, .Map) = try doc1.get(obj: .ROOT, key: record.id.uuidString) {
            try AutomergeTimeRecord(document: doc1, objectId: objId, recordId: record.id).setImages(["a.jpg"])
        }

        if case .Object(let objId, .Map) = try doc2.get(obj: .ROOT, key: record.id.uuidString) {
            try AutomergeTimeRecord(document: doc2, objectId: objId, recordId: record.id).setImages(["b.jpg"])
        }

        try doc1.merge(other: doc2)

        // Note: List merge behavior - last writer wins for setImages
        // This is acceptable for our single-user use case
    }
}

10.2 Storage Service Tests

Create Shared/Tests/MinutaSharedTests/AutomergeStorageServiceTests.swift:

import XCTest
@testable import MinutaShared

final class AutomergeStorageServiceTests: XCTestCase {
    var tempDirectory: URL!
    var service: AutomergeStorageService!

    override func setUp() async throws {
        tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("AutomergeTests-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
        service = AutomergeStorageService(storageURL: tempDirectory)
    }

    override func tearDown() async throws {
        try? FileManager.default.removeItem(at: tempDirectory)
    }

    func testSaveAndLoadTags() async throws {
        let tags = [
            Tag(id: UUID(), name: "Work", color: "#4A90D9"),
            Tag(id: UUID(), name: "Personal", color: "#E57373")
        ]
        try await service.saveTags(tags)
        let loaded = try await service.loadTags()
        XCTAssertEqual(loaded.count, 2)
    }

    func testSaveAndLoadRecords() async throws {
        let record = TimeRecord(id: UUID(), startTime: Date(), comment: "Test")
        try await service.saveRecord(record)
        let loaded = try await service.loadAllRecords()
        XCTAssertEqual(loaded.count, 1)
        XCTAssertEqual(loaded[0].comment, "Test")
    }

    func testLoadRecordsDateRange() async throws {
        let now = Date()
        let yesterday = now.addingTimeInterval(-86400)
        let tomorrow = now.addingTimeInterval(86400)

        try await service.saveRecord(TimeRecord(id: UUID(), startTime: yesterday))
        try await service.saveRecord(TimeRecord(id: UUID(), startTime: now))
        try await service.saveRecord(TimeRecord(id: UUID(), startTime: tomorrow))

        let todayRecords = try await service.loadRecords(from: now.addingTimeInterval(-1), to: now.addingTimeInterval(1))
        XCTAssertEqual(todayRecords.count, 1)
    }

    func testDeleteRecord() async throws {
        let record = TimeRecord(id: UUID(), startTime: Date())
        try await service.saveRecord(record)
        try await service.deleteRecord(record)
        let loaded = try await service.loadAllRecords()
        XCTAssertTrue(loaded.isEmpty)
    }

    func testPersistenceAcrossInstances() async throws {
        let tag = Tag(id: UUID(), name: "Work", color: "#4A90D9")
        try await service.saveTags([tag])

        let newService = AutomergeStorageService(storageURL: tempDirectory)
        let loaded = try await newService.loadTags()
        XCTAssertEqual(loaded.count, 1)
        XCTAssertEqual(loaded[0].name, "Work")
    }

    func testCacheInvalidation() async throws {
        let tag = Tag(id: UUID(), name: "Work", color: "#4A90D9")
        try await service.saveTags([tag])
        await service.invalidateCache()
        let loaded = try await service.loadTags()
        XCTAssertEqual(loaded.count, 1)
    }
}

10.3 Conflict Resolution Tests

Create Shared/Tests/MinutaSharedTests/ConflictResolutionTests.swift:

import XCTest
import Automerge
@testable import MinutaShared

final class ConflictResolutionTests: XCTestCase {
    var tempDirectory: URL!
    var resolver: ConflictResolutionService!

    override func setUp() async throws {
        tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("ConflictTests-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
        resolver = ConflictResolutionService(storageURL: tempDirectory)
    }

    override func tearDown() async throws {
        try? FileManager.default.removeItem(at: tempDirectory)
    }

    func testNoConflicts() async throws {
        let doc = Document()
        try doc.save().write(to: tempDirectory.appendingPathComponent("tags.automerge"))

        let result = try await resolver.resolveConflicts()
        XCTAssertEqual(result.conflictsResolved, 0)
    }

    func testDropboxConflictDetection() async throws {
        let doc1 = Document()
        try doc1.put(obj: .ROOT, key: "test", value: .String("value1"))
        try doc1.save().write(to: tempDirectory.appendingPathComponent("tags.automerge"))

        let doc2 = Document()
        try doc2.put(obj: .ROOT, key: "test", value: .String("value2"))
        try doc2.save().write(to: tempDirectory.appendingPathComponent("tags (conflicted copy 2026-01-06).automerge"))

        let result = try await resolver.resolveConflicts()
        XCTAssertEqual(result.conflictsResolved, 1)

        // Conflict file should be deleted
        XCTAssertFalse(FileManager.default.fileExists(atPath: tempDirectory.appendingPathComponent("tags (conflicted copy 2026-01-06).automerge").path))
    }

    func testiCloudConflictDetection() async throws {
        let doc1 = Document()
        try doc1.save().write(to: tempDirectory.appendingPathComponent("tags.automerge"))

        let doc2 = Document()
        try doc2.save().write(to: tempDirectory.appendingPathComponent("tags 2.automerge"))

        let result = try await resolver.resolveConflicts()
        XCTAssertEqual(result.conflictsResolved, 1)
    }

    func testGoogleDriveConflictDetection() async throws {
        let doc1 = Document()
        try doc1.save().write(to: tempDirectory.appendingPathComponent("01.automerge"))

        let doc2 = Document()
        try doc2.save().write(to: tempDirectory.appendingPathComponent("01 (1).automerge"))

        let result = try await resolver.resolveConflicts()
        XCTAssertEqual(result.conflictsResolved, 1)
    }

    func testMergePreservesData() async throws {
        // Create two documents with different data
        let doc1 = Document()
        try doc1.put(obj: .ROOT, key: "key1", value: .String("from_doc1"))
        try doc1.save().write(to: tempDirectory.appendingPathComponent("tags.automerge"))

        let doc2 = Document()
        try doc2.put(obj: .ROOT, key: "key2", value: .String("from_doc2"))
        try doc2.save().write(to: tempDirectory.appendingPathComponent("tags (conflicted copy).automerge"))

        _ = try await resolver.resolveConflicts()

        // Load merged document and verify both keys present
        let merged = try Document(Data(contentsOf: tempDirectory.appendingPathComponent("tags.automerge")))
        if case .Scalar(.String(let v1)) = try merged.get(obj: .ROOT, key: "key1") {
            XCTAssertEqual(v1, "from_doc1")
        } else {
            XCTFail("key1 not found")
        }
        if case .Scalar(.String(let v2)) = try merged.get(obj: .ROOT, key: "key2") {
            XCTAssertEqual(v2, "from_doc2")
        } else {
            XCTFail("key2 not found")
        }
    }
}

10.4 Migration Tests

Create Shared/Tests/MinutaSharedTests/MigrationServiceTests.swift:

import XCTest
@testable import MinutaShared

final class MigrationServiceTests: XCTestCase {
    var tempDirectory: URL!
    var legacyService: LocalFileStorageService!
    var migrationService: MigrationService!

    override func setUp() async throws {
        tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("MigrationTests-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
        legacyService = LocalFileStorageService(baseURL: tempDirectory)
        migrationService = MigrationService(storageURL: tempDirectory)
        MigrationService.resetMigrationState()
    }

    override func tearDown() async throws {
        try? FileManager.default.removeItem(at: tempDirectory)
        MigrationService.resetMigrationState()
    }

    func testMigrationWithTagsAndRecords() async throws {
        // Create legacy data
        try await legacyService.saveTags([
            Tag(id: UUID(), name: "Work", color: "#4A90D9"),
            Tag(id: UUID(), name: "Personal", color: "#E57373")
        ])
        try await legacyService.saveRecord(TimeRecord(id: UUID(), startTime: Date(), comment: "Test 1"))
        try await legacyService.saveRecord(TimeRecord(id: UUID(), startTime: Date(), comment: "Test 2"))

        XCTAssertTrue(await migrationService.needsMigration)

        let result = try await migrationService.migrate()

        XCTAssertEqual(result.tagsCount, 2)
        XCTAssertEqual(result.recordsCount, 2)
        XCTAssertTrue(result.isComplete)
        XCTAssertFalse(await migrationService.needsMigration)
    }

    func testMigrationVerification() async throws {
        try await legacyService.saveTags([Tag(id: UUID(), name: "Work", color: "#4A90D9")])
        try await legacyService.saveRecord(TimeRecord(id: UUID(), startTime: Date()))

        _ = try await migrationService.migrate()

        let verified = try await migrationService.verifyMigration()
        XCTAssertTrue(verified)
    }

    func testCleanupLegacyData() async throws {
        try await legacyService.saveTags([Tag(id: UUID(), name: "Work", color: "#4A90D9")])
        _ = try await migrationService.migrate()
        try await migrationService.cleanupLegacyData()

        let tagsJson = tempDirectory.appendingPathComponent("tags.json")
        XCTAssertFalse(FileManager.default.fileExists(atPath: tagsJson.path))

        let tagsAutomerge = tempDirectory.appendingPathComponent("tags.automerge")
        XCTAssertTrue(FileManager.default.fileExists(atPath: tagsAutomerge.path))
    }

    func testNoMigrationNeededForFreshInstall() async throws {
        XCTAssertFalse(await migrationService.needsMigration)
    }

    func testRunningTimerPreservedDuringMigration() async throws {
        let runningRecord = TimeRecord(id: UUID(), startTime: Date(), endTime: nil, comment: "Running")
        try await legacyService.saveRecord(runningRecord)

        _ = try await migrationService.migrate()

        let automergeService = AutomergeStorageService(storageURL: tempDirectory)
        let running = try await automergeService.loadRunningRecords()
        XCTAssertEqual(running.count, 1)
        XCTAssertTrue(running[0].isRunning)
    }
}

10.5 HybridStorageService Tests

Create Shared/Tests/MinutaSharedTests/HybridStorageServiceTests.swift:

import XCTest
@testable import MinutaShared

final class HybridStorageServiceTests: XCTestCase {
    var tempDirectory: URL!
    var legacyService: LocalFileStorageService!
    var hybridService: HybridStorageService!

    override func setUp() async throws {
        tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("HybridTests-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
        legacyService = LocalFileStorageService(baseURL: tempDirectory)
        hybridService = HybridStorageService(storageURL: tempDirectory)
        MigrationService.resetMigrationState()
    }

    override func tearDown() async throws {
        try? FileManager.default.removeItem(at: tempDirectory)
        MigrationService.resetMigrationState()
    }

    func testReadFromAutomergeWhenAvailable() async throws {
        // Create automerge data directly
        let automergeService = AutomergeStorageService(storageURL: tempDirectory)
        try await automergeService.saveTags([Tag(id: UUID(), name: "Automerge Tag", color: "#4A90D9")])

        try await hybridService.checkAndMigrate()
        let tags = try await hybridService.loadTags()

        XCTAssertEqual(tags.count, 1)
        XCTAssertEqual(tags[0].name, "Automerge Tag")
    }

    func testWriteAlwaysGoesToAutomerge() async throws {
        let tag = Tag(id: UUID(), name: "New Tag", color: "#4A90D9")
        try await hybridService.saveTags([tag])

        // Verify it's in automerge
        let automergeService = AutomergeStorageService(storageURL: tempDirectory)
        let automergeTags = try await automergeService.loadTags()
        XCTAssertEqual(automergeTags.count, 1)

        // Verify it's NOT in legacy
        let legacyTags = try await legacyService.loadTags()
        XCTAssertEqual(legacyTags.count, 0)
    }

    func testMigrationTriggeredOnCheckAndMigrate() async throws {
        try await legacyService.saveTags([Tag(id: UUID(), name: "Legacy", color: "#4A90D9")])

        try await hybridService.checkAndMigrate()

        let automergeService = AutomergeStorageService(storageURL: tempDirectory)
        let tags = try await automergeService.loadTags()
        XCTAssertEqual(tags.count, 1)
        XCTAssertEqual(tags[0].name, "Legacy")
    }
}

10.6 Acceptance Criteria

  • All unit tests pass
  • All integration tests pass
  • Migration preserves running timers
  • Conflict resolution handles all cloud providers

11. Phase 10: Rollout Strategy

11.1 Development Phase

  • All code implemented
  • All tests pass
  • Code review complete

11.2 Alpha Testing

  • TestFlight internal deployment
  • Test with real user data
  • Monitor for crashes

11.3 Beta Testing

  • External beta testers
  • Collect feedback
  • Address issues

11.4 Production Rollout

  • Release to App Store
  • Monitor crash rates
  • Rollback if crash rate >1%

12. Phase 11: Post-Migration Cleanup

12.1 Phase 11a: Remove Transition Code (2 weeks after stable)

TaskFiles
Remove HybridStorageServiceHybridStorageService.swift
Update AppState to use AutomergeStorageService directlyMinutaApp.swift
Update AppIntentsAppIntents.swift
Remove HybridStorageService testsHybridStorageServiceTests.swift

12.2 Phase 11b: Remove Legacy Code (1 month after stable)

TaskFiles
Remove MigrationServiceMigrationService.swift, MigrationServiceTests.swift
Remove legacy JSON reading from LocalFileStorageServiceKeep for reference or archive
Clean up UserDefaults migration keysCode to call MigrationService.resetMigrationState()
Archive 503-crdt-data-structures.mdMove to docs/archive/

12.3 Backup Cleanup Strategy

Add to Settings:

  • “Clear migration backup” button (appears if backup exists)
  • Shows backup age and size
  • Confirms before deletion

13. Rollback Plan

13.1 Immediate Rollback

If critical issues found:

  1. Submit app update disabling Automerge writes
  2. HybridStorageService falls back to legacy
  3. User data preserved in backup

13.2 User-Level Recovery

For individual users:

  1. Locate backup: ~/Documents/Minuta-backup-{date}/
  2. Replace current data with backup
  3. Reset migration: MigrationService.resetMigrationState()

14. Success Criteria

14.1 Functional

  • All existing functionality works
  • Data migration is lossless
  • Conflict resolution works

14.2 Performance

  • Initial load: <2s for 1000 records
  • Save: <100ms per record
  • Migration: <30s for 1000 records
  • Binary size: <3MB increase

14.3 Quality

  • Zero data loss
  • Crash rate: <0.1%
  • All tests pass

Related Documents