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
- Prerequisites
- Phase 1: Add Automerge Dependency
- Phase 2: Create Automerge Utilities
- Phase 3: Create Automerge Types
- Phase 4: Implement AutomergeStorageService
- Phase 5: Create Migration Service
- Phase 6: Implement Conflict Resolution
- Phase 7: Integration & Dual-Read Support
- Phase 8: Update All LocalFileStorageService Usages
- Phase 9: Testing
- Phase 10: Rollout Strategy
- Phase 11: Post-Migration Cleanup
- Rollback Plan
- 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
| Decision | Choice | Rationale |
|---|---|---|
| Document model | Hybrid (tags in one doc, records per month) | Balances granularity and sync efficiency |
| Image storage | Keep separate files (unchanged) | Binary data shouldn’t be in CRDT |
| Comment field | Simple string (LWW) | Character-level CRDT is overkill for single-user |
| Timestamp format | Milliseconds (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 Automergecompiles - 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
| File | Current Usage | Change Required |
|---|---|---|
Minuta/Sources/MinutaApp.swift | LocalFileStorageService in AppState | Use HybridStorageService |
Minuta/Sources/AppIntents.swift | LocalFileStorageService directly | Use 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)
| Task | Files |
|---|---|
| Remove HybridStorageService | HybridStorageService.swift |
| Update AppState to use AutomergeStorageService directly | MinutaApp.swift |
| Update AppIntents | AppIntents.swift |
| Remove HybridStorageService tests | HybridStorageServiceTests.swift |
12.2 Phase 11b: Remove Legacy Code (1 month after stable)
| Task | Files |
|---|---|
| Remove MigrationService | MigrationService.swift, MigrationServiceTests.swift |
| Remove legacy JSON reading from LocalFileStorageService | Keep for reference or archive |
| Clean up UserDefaults migration keys | Code to call MigrationService.resetMigrationState() |
| Archive 503-crdt-data-structures.md | Move 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:
- Submit app update disabling Automerge writes
- HybridStorageService falls back to legacy
- User data preserved in backup
13.2 User-Level Recovery
For individual users:
- Locate backup:
~/Documents/Minuta-backup-{date}/ - Replace current data with backup
- 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
- 504-automerge-migration - Automerge exploration and design (timestamp handling: uses milliseconds)
- 503-crdt-data-structures - Custom CRDT alternative (superseded by Automerge decision)
- 502-multi-cloud-sync - Cloud sync architecture
- 301-file-storage - Current file storage (protocol: FileStorageServiceProtocol)
- 910-backlog - Backlog item reference