import Foundation import IndieSync import SwiftData enum ICloudStatus: Equatable, Sendable { case checking case available case unavailable } /// Orchestrates iCloud JSON files (the sole source of truth) and the /// SwiftData cache. Also exposes the domain operations the UI calls: create, /// update, delete a note. /// /// The file layer lives in the IndieSync package: `DocumentFileStore` for /// coordinated, conflict-resolved, eviction-safe I/O; `TombstoneStore` for /// soft-delete stubs; `MetadataObserver` for change events. Data flows one /// way — writes touch files only; the observers are the sole writers of the /// cache, so a local save reaches it through the same pipeline as a change /// from another device. /// /// Layout inside the container (record paths in this engine are /// Records-relative and get prefixed via `dataPath(_:)` at the store /// boundary): /// ``` /// Documents/ /// Records/YYYY/MM/.json /// Stubs/YYYY/MM/.json ← soft-delete markers /// ``` @Observable @MainActor final class SyncEngine { private(set) var iCloudStatus: ICloudStatus = .checking private(set) var isSyncing = false /// Last non-fatal sync failure, surfaced for the UI to render. Clears on /// the next successful write or rebuild. private(set) var lastSyncError: String? /// Fired after a watch recording is ingested, carrying the new note's id, so /// the composition root can enqueue on-device transcription. Wired by /// `AppServices`; nil until then, so ingestion is safe before the /// transcription stack exists. var onAudioNoteIngested: ((ULID) -> Void)? /// Fired when a staged watch recording reaches a **terminal** state on this /// device — durably ingested, or permanently vetoed (tombstoned / already /// present). The composition root wires this to send the ingest ack that /// lets the watch delete its local copy; the watch keeps (and redelivers) /// anything never acked, so transient failures stay silent here on purpose. var onWatchRecordingHandled: ((String) -> Void)? static let recordsDirectory = "Records" static let stubsDirectory = "Stubs" /// Store-rooted path of a record (record paths are Records-relative /// everywhere inside this engine). static func dataPath(_ recordsRelativePath: String) -> String { "\(recordsDirectory)/\(recordsRelativePath)" } private let modelContainer: ModelContainer private var store: DocumentFileStore? private var tombstones: TombstoneStore? private var dataMonitor: MetadataObserver? private var stubMonitor: MetadataObserver? private var dataMonitorTask: Task? private var stubMonitorTask: Task? /// Guards `ingestStagedWatchRecordings` against overlapping triggers (connect, /// foreground reconcile, receiver callback) double-ingesting the same inbox. private var isDrainingInbox = false private var cacheContext: ModelContext { modelContainer.mainContext } init(modelContainer: ModelContainer) { self.modelContainer = modelContainer } // MARK: - Connection private var connectAttempt = 0 /// Resolves the ubiquity container and connects. Safe to call repeatedly — /// the gate's "Try Again" button and foreground reactivation reuse it. func connect() async { guard iCloudStatus != .available else { return } connectAttempt += 1 let attempt = connectAttempt iCloudStatus = .checking // Blocking call — never on the main thread. let url = await Task.detached { FileManager.default.url(forUbiquityContainerIdentifier: nil) }.value guard let containerURL = url else { if attempt == connectAttempt { iCloudStatus = .unavailable } return } let store = DocumentFileStore( root: containerURL.appendingPathComponent("Documents", isDirectory: true)) // File ops inside the container can hang indefinitely while the // iCloud account is in a bad state — flip the gate to "unavailable" // after a timeout, but let the attempt keep running and finish // connecting if it eventually succeeds. let timeout = Task { [weak self] in try? await Task.sleep(for: .seconds(20)) guard let self, !Task.isCancelled else { return } if self.iCloudStatus == .checking, attempt == self.connectAttempt { self.iCloudStatus = .unavailable } } await store.prepareDirectories([Self.recordsDirectory, Self.stubsDirectory]) timeout.cancel() guard attempt == connectAttempt else { return } // superseded by a retry self.store = store self.tombstones = TombstoneStore(store: store, stubsDirectory: Self.stubsDirectory) iCloudStatus = .available startMonitoring() await syncCacheOnConnect() performLaunchMaintenance() } // MARK: - Domain operations @discardableResult func createNote( text: String, source: Note.Source = .typed, tags: [String] = [], context: CaptureContext ) async throws -> Note { let note = Note.create(text: text, source: source, tags: tags, context: context) try await save(note) return note } func updateNote(_ note: Note) async throws { var updated = note updated.meta.modifiedAt = Date() try await save(updated) } func deleteNote(id: ULID) async throws { // Without the file layer only the cache row could go — and the iCloud // file would re-import on the next rebuild, resurrecting the note. // Refuse instead. guard let tombstones else { report("Delete failed — iCloud not connected") throw SyncError.iCloudUnavailable } let path = Note.relativePath(forID: id) try await tombstones.softDelete(dataRelativePath: Self.dataPath(path), at: path) // Drop the audio sidecar too. Best-effort and harmless when the note had // no audio (nothing to remove); the sidecar never gets its own stub, so // this is the only thing that reaps it. if let store { try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path))) } lastSyncError = nil // Cache cleanup happens via the monitors (file removal + stub // appearance both drop the row), but do it eagerly too so the UI // updates instantly even while iCloud event delivery lags. removeFromCache(jsonRelativePath: path) } /// Reads the JSON file — the authority — so read-after-write flows never /// act on a stale cache. Falls back to the near-lossless cache copy only /// if the file is unreadable (offline or not yet downloaded). func loadNote(id: ULID) async -> Note? { let path = Note.relativePath(forID: id) if let store, let note = try? await store.read(Note.self, from: Self.dataPath(path)), note.isReadable { return migrateIfNeeded(note) } guard let entity = NoteMapper.fetch(id: id.stringValue, in: cacheContext) else { return nil } return NoteMapper.note(from: entity) } // MARK: - Audio notes /// Create a voice note: write the `.m4a` sidecar **before** the JSON, so a /// note that claims audio can never sync to another device ahead of its /// bytes (which would surface a pending audio note whose `.m4a` doesn't yet /// exist). The note starts with empty (or provisional) text and a `.pending` /// transcription owned by `transcriberDeviceID`; the transcript arrives later /// through `applyTranscription`. Like every other write, the cache catches up /// via the monitor — no direct upsert here. @discardableResult func createAudioNote( audioFileURL: URL, duration: TimeInterval, text: String = "", tags: [String] = [], context: CaptureContext, transcriberDeviceID: String ) async throws -> Note { guard let store else { report("Note not saved — iCloud not connected") throw SyncError.iCloudUnavailable } let audio = Note.AudioInfo( duration: duration, transcriberDeviceID: transcriberDeviceID, transcriptionStatus: .pending, transcriptionLocaleID: nil, transcribedAt: nil ) let note = Note.create(text: text, source: .transcribed, tags: tags, context: context, audio: audio) do { let audioData = try Data(contentsOf: audioFileURL) try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: note.id))) _ = try await store.write(note, to: Self.dataPath(note.relativePath)) } catch { report("Failed to save audio note", error) throw error } lastSyncError = nil return note } /// Load a note's audio bytes for playback. Fail-fast (`.requestOnly`): an /// evicted file kicks off a download and returns nil immediately rather than /// stalling the player ~30s — the UI shows a "downloading" retry state and /// the bytes land on a later attempt (or via `requestAudioDownload`). func loadAudioData(for id: ULID) async -> Data? { guard let store else { return nil } return try? await store.readData(from: audioPath(for: id), downloadPolicy: .requestOnly) } /// Load a note's audio with a bounded wait for an evicted file to download — /// for re-transcription, where blocking to fetch the source is acceptable /// (unlike interactive playback, which uses `loadAudioData`). Throws on a /// download timeout rather than yielding a dataless placeholder. func loadAudioDataWaiting(for id: ULID) async throws -> Data { guard let store else { throw SyncError.iCloudUnavailable } return try await store.readData(from: audioPath(for: id), downloadPolicy: .waitBounded) } /// Warm an evicted audio file ahead of an anticipated playback, so a later /// `loadAudioData` finds the bytes already local. Fire-and-forget. func requestAudioDownload(for id: ULID) async { guard let store else { return } await store.requestDownload(at: audioPath(for: id)) } /// Store-rooted `.m4a` sidecar path for a note. Audio exists only on v2+ /// records, which file under the current UTC bucket, so recomputing the path /// from the id is always correct here. private func audioPath(for id: ULID) -> String { Self.dataPath(Note.audioRelativePath(forID: id)) } // MARK: - Transcription /// Land a completed transcription onto a note as a new attempt. Re-reads /// the file (the authority) so a concurrent edit isn't clobbered. /// `payload.text` — the user's own writing — is never touched: transcripts /// accumulate in `audio.attempts` and the latest one is what the UI shows. /// The cache updates through the monitor as usual (via `updateNote`). func applyTranscription( id: ULID, text: String, localeID: String, confidence: Double? = nil, languageWasForced: Bool = false ) async throws { guard let store else { throw SyncError.iCloudUnavailable } guard let raw = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))), raw.isReadable else { return } var note = migrateIfNeeded(raw) guard var audio = note.payload.audio else { return } let now = Date() audio.attempts.append(Note.TranscriptionAttempt( text: text, localeID: localeID, confidence: confidence, transcribedAt: now, languageWasForced: languageWasForced )) audio.transcriptionStatus = .done audio.transcriptionLocaleID = localeID audio.transcribedAt = now note.payload.audio = audio try await updateNote(note) } /// Flag a note's transcription as failed (retryable only by an explicit /// re-transcribe). Re-reads first so it can't resurrect a since-deleted or /// non-audio note. func markTranscriptionFailed(id: ULID) async throws { guard let store else { throw SyncError.iCloudUnavailable } guard var note = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))), note.isReadable, var audio = note.payload.audio else { return } audio.transcriptionStatus = .failed note.payload.audio = audio try await updateNote(note) } /// Reassign a note's transcription to *this* device and reset it to /// `.pending`, optionally forcing a language (`localeID`, else auto-detect). /// Claiming `transcriberDeviceID` is also the recovery path when the original /// recording device is gone — any device can take a stuck note over. func requestRetranscription(id: ULID, localeID: String?) async throws { guard let store else { throw SyncError.iCloudUnavailable } guard var note = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))), note.isReadable, var audio = note.payload.audio else { return } audio.transcriptionStatus = .pending audio.transcriberDeviceID = InstallIdentity.installID audio.transcriptionLocaleID = localeID note.payload.audio = audio try await updateNote(note) } // MARK: - Watch ingestion /// Local directory where the phone receiver (`PhoneWatchReceiver`, a later /// stage) stages recordings handed over from the watch before they are /// ingested. It sits outside the iCloud container on purpose: the watch has /// no iCloud, so a delivered recording becomes durable only once /// `ingestWatchRecording` writes it into the container. Each staged /// recording is a `.m4a` plus a `.meta.plist` sidecar carrying /// `id` / `recordedAt` / `duration` / `timeZoneID`. /// `nonisolated` so the phone receiver can stage a delivery synchronously in /// its nonisolated `WCSessionDelegate` callback (the system reclaims the inbox /// file the moment that returns). Pure — no actor state — so it is safe to /// share this one source of truth for the inbox path with the drain. nonisolated static var watchInboxURL: URL { URL.applicationSupportDirectory.appending(path: "WatchInbox", directoryHint: .isDirectory) } /// Idempotent drain of the watch staging inbox. Called after `connect()`, on /// foreground reconcile, and (a later stage) on the receiver callback, so a /// recording delivered while the app was dead still lands. Serialized against /// itself so overlapping triggers can't double-ingest. func ingestStagedWatchRecordings() async { guard store != nil, !isDrainingInbox else { return } isDrainingInbox = true defer { isDrainingInbox = false } for staged in Self.stagedWatchRecordings() { await ingestWatchRecording( audioURL: staged.audioURL, id: staged.id, recordedAt: staged.recordedAt, duration: staged.duration, context: staged.context ) } let orphans = Self.watchInboxOrphanCount() if orphans > 0 { print("[Sync] Watch inbox holds \(orphans) recording(s) without readable metadata — not ingestible by this build") } } /// Ingest one recording handed over from the watch — the single sanctioned /// direct-cache-upsert path (icloud-sync-engine §9). The phone is where the /// note first becomes durable, so: veto if the note was already deleted /// (tombstone) or already ingested (duplicate redelivery), then write audio /// **before** JSON (same ordering invariant as `createAudioNote`), upsert the /// cache directly (a WatchConnectivity delivery has no other live trigger to /// refresh an open list), remove the staged files, and fire /// `onAudioNoteIngested` so transcription can enqueue. Returns whether a new /// note was created. @discardableResult func ingestWatchRecording( audioURL: URL, id: ULID, recordedAt: Date, duration: TimeInterval, context: CaptureContext ) async -> Bool { guard let store, let tombstones else { return false } // Permanent skips — clear the stale staged copy so it stops re-draining, // and ack so the watch drops its copy too (redelivery would just loop). if await tombstones.stubExists(id: id.stringValue) { Self.removeStagedFiles(audioURL: audioURL) onWatchRecordingHandled?(id.stringValue) return false } let jsonPath = Note.relativePath(forID: id) if await store.fileExists(Self.dataPath(jsonPath)) { Self.removeStagedFiles(audioURL: audioURL) onWatchRecordingHandled?(id.stringValue) return false } let note = Note.createIngested( id: id, duration: duration, transcriberDeviceID: InstallIdentity.installID, context: context ) do { let audioData = try Data(contentsOf: audioURL) try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: id))) try await store.write(note, to: Self.dataPath(jsonPath)) } catch { // Transient (iCloud write, or the staged file vanished mid-drain): // leave the staged files for the next drain to retry. report("Failed to ingest watch recording \(id.stringValue)", error) return false } NoteMapper.upsert(note, relativePath: jsonPath, into: cacheContext) try? cacheContext.save() Self.removeStagedFiles(audioURL: audioURL) lastSyncError = nil print("[Sync] Ingested watch recording \(id.stringValue) recorded \(recordedAt)") onWatchRecordingHandled?(id.stringValue) onAudioNoteIngested?(id) return true } /// `.m4a` files sitting in the watch inbox with no readable metadata sidecar /// — quarantined forward-gate deliveries or the residue of a failed staging. /// The drain can never ingest them; surfaced so they're at least visible. nonisolated static func watchInboxOrphanCount() -> Int { let fm = FileManager.default let entries = (try? fm.contentsOfDirectory(at: watchInboxURL, includingPropertiesForKeys: nil)) ?? [] let sidecarBases = Set(entries.filter { $0.lastPathComponent.hasSuffix(".meta.plist") } .map { $0.lastPathComponent.replacingOccurrences(of: ".meta.plist", with: "") }) return entries.filter { $0.pathExtension == "m4a" && !sidecarBases.contains($0.deletingPathExtension().lastPathComponent) }.count } /// One recording staged in the watch inbox: the `.m4a` and the metadata the /// receiver wrote in its `.meta.plist` sidecar. private struct StagedRecording { var id: ULID var recordedAt: Date var duration: TimeInterval var context: CaptureContext var audioURL: URL } /// Enumerate the staging inbox, pairing each `.meta.plist` with its /// `.m4a`. A sidecar whose id doesn't parse, whose required fields are /// missing, or whose audio is absent is skipped (a half-delivered pair can't /// be ingested; the next full delivery re-stages it). private static func stagedWatchRecordings() -> [StagedRecording] { let fm = FileManager.default guard let entries = try? fm.contentsOfDirectory( at: watchInboxURL, includingPropertiesForKeys: nil) else { return [] } var staged: [StagedRecording] = [] for metaURL in entries where metaURL.pathExtension == "plist" { // `.meta.plist` → drop `.plist` then `.meta` to the ULID base. let base = metaURL.deletingPathExtension().deletingPathExtension().lastPathComponent guard let id = ULID(string: base), let data = try? Data(contentsOf: metaURL), let dict = try? PropertyListSerialization.propertyList( from: data, options: [], format: nil) as? [String: Any], let duration = dict["duration"] as? Double, let recordedAt = dict["recordedAt"] as? Date else { continue } let audioURL = watchInboxURL.appending(path: "\(base).m4a") guard fm.fileExists(atPath: audioURL.path) else { continue } var context = CaptureContext.empty context.device = "Watch" context.timeZoneID = dict["timeZoneID"] as? String staged.append(StagedRecording( id: id, recordedAt: recordedAt, duration: duration, context: context, audioURL: audioURL)) } return staged } /// Remove a staged recording's `.m4a` and its `.meta.plist` sibling. private static func removeStagedFiles(audioURL: URL) { let fm = FileManager.default try? fm.removeItem(at: audioURL) try? fm.removeItem(at: audioURL.deletingPathExtension().appendingPathExtension("meta.plist")) } // MARK: - Persistence plumbing /// Writes touch the file layer ONLY. iCloud JSON is the source of truth: /// with no file store the write would exist nowhere durable (a cache-only /// copy is wiped by the next rebuild), so refuse the save. The UI gate is /// a courtesy; this throw is the invariant. private func save(_ note: Note) async throws { guard let store else { report("Note not saved — iCloud not connected") throw SyncError.iCloudUnavailable } do { _ = try await store.write(note, to: Self.dataPath(note.relativePath)) } catch { report("Failed to save note", error) throw error } lastSyncError = nil } /// Record a non-fatal sync failure: log it and publish it as /// `lastSyncError` for the UI to render. private func report(_ message: String, _ error: Error? = nil) { let full = error.map { "\(message): \($0.localizedDescription)" } ?? message print("[Sync] \(full)") lastSyncError = full } private func removeFromCache(jsonRelativePath: String) { let entities = NoteMapper.fetch(jsonRelativePath: jsonRelativePath, in: cacheContext) guard !entities.isEmpty else { return } entities.forEach(cacheContext.delete) try? cacheContext.save() } private func removeFromCache(ids: Set) { for id in ids { if let entity = NoteMapper.fetch(id: id, in: cacheContext) { cacheContext.delete(entity) } } try? cacheContext.save() } // MARK: - Monitoring private func startMonitoring() { guard let store else { return } // Idempotent: tear down any existing monitors first so a reconnect // can't leave two live NSMetadataQuery monitors feeding duplicate events. stopMonitoring() let recordsURL = store.rootURL.appendingPathComponent(Self.recordsDirectory, isDirectory: true) let dataMonitor = MetadataObserver(documentsURL: recordsURL) self.dataMonitor = dataMonitor dataMonitor.start() dataMonitorTask = Task { [weak self] in for await batch in dataMonitor.events() { for event in batch { await self?.handleFileEvent(event) } } } let stubsURL = store.rootURL.appendingPathComponent(Self.stubsDirectory, isDirectory: true) let stubMonitor = MetadataObserver(documentsURL: stubsURL) self.stubMonitor = stubMonitor stubMonitor.start() stubMonitorTask = Task { [weak self] in for await batch in stubMonitor.events() { for event in batch { if case .added(let path) = event { await self?.handleStubAdded(relativePath: path) } } } } } private func stopMonitoring() { dataMonitorTask?.cancel() stubMonitorTask?.cancel() dataMonitorTask = nil stubMonitorTask = nil dataMonitor?.stop() stubMonitor?.stop() dataMonitor = nil stubMonitor = nil } private func handleFileEvent(_ event: FileChangeEvent) async { guard let store, let tombstones else { return } switch event { case .added(let path), .modified(let path): // Match stubs by document id, not path equality, so an evicted // stub still vetoes resurrection. let id = DocumentFileStore.documentID(fromRelativePath: path) if await tombstones.stubExists(id: id) { try? await store.remove(at: Self.dataPath(path)) try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path))) return } await importRecord(at: path, using: store) case .removed(let path): removeFromCache(jsonRelativePath: path) } } private func handleStubAdded(relativePath: String) async { if let store { try? await store.remove(at: Self.dataPath(relativePath)) try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: relativePath))) } removeFromCache(jsonRelativePath: relativePath) } private func importRecord(at path: String, using store: DocumentFileStore) async { // The store's read resolves conflicts and force-downloads evicted // files (bounded wait) before the coordinated read. guard let note = try? await store.read(Note.self, from: Self.dataPath(path)) else { return } // Forward gate: a document written by a newer app version is // quarantined — Codable would silently drop its unknown fields. guard note.isReadable else { print("[Sync] Skipping \(path) — schema \(note.schemaVersion) is newer than this app understands") return } NoteMapper.upsert(migrateIfNeeded(note), relativePath: path, into: cacheContext) try? cacheContext.save() } // MARK: - Cache rebuild / reconcile /// Bring the cache in line with iCloud on connect: full rebuild when the /// cache is empty (first launch, post-wipe), incremental reconcile /// otherwise — records synced down while the app was closed must appear. private func syncCacheOnConnect() async { guard store != nil else { return } let count = (try? cacheContext.fetchCount(FetchDescriptor())) ?? 0 if count == 0 { await rebuildCache() } else { await reconcile() } } /// Diff iCloud's files against the cache and settle the difference: /// import records that synced down while the app was closed/backgrounded, /// drop rows whose file was deleted elsewhere, and clean up files a stub /// has since tombstoned. Runs on every connect and foreground return. func reconcile() async { guard let store, let tombstones else { return } let filePaths = await listRecordPaths(in: store) let stubIDs = await tombstones.listStubIDs() var livePathsByID: [String: String] = [:] var orphanedPaths: [String] = [] for path in filePaths { let id = DocumentFileStore.documentID(fromRelativePath: path) if stubIDs.contains(id) { orphanedPaths.append(path) } else { livePathsByID[id] = path } } var descriptor = FetchDescriptor() descriptor.propertiesToFetch = [\.id] let cachedIDs = Set(((try? cacheContext.fetch(descriptor)) ?? []).map(\.id)) let toImport = Set(livePathsByID.keys).subtracting(cachedIDs) let toRemove = cachedIDs.subtracting(livePathsByID.keys) guard !toImport.isEmpty || !toRemove.isEmpty || !orphanedPaths.isEmpty else { return // already in sync — the common foreground case, kept cheap } print("[Sync] Reconcile: +\(toImport.count)/-\(toRemove.count) notes, \(orphanedPaths.count) orphaned files") isSyncing = true defer { isSyncing = false } // Clean up files a stub has tombstoned (mirrors the rebuild path), // including each record's audio sidecar sibling. for path in orphanedPaths { try? await store.remove(at: Self.dataPath(path)) try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path))) } removeFromCache(ids: toRemove) // Import the missing records through the same conflict-resolved, // eviction-safe, forward-gated path the monitors use. for id in toImport.sorted() { guard let path = livePathsByID[id] else { continue } await importRecord(at: path, using: store) } } func rebuildCache() async { guard let store, let tombstones else { return } isSyncing = true defer { isSyncing = false } await store.downloadEvictedFiles(inSubdirectory: Self.recordsDirectory) for _ in 0..<30 { if await store.countEvictedFiles(inSubdirectory: Self.recordsDirectory) == 0 { break } try? await Task.sleep(for: .seconds(2)) } do { try cacheContext.delete(model: NoteEntity.self) try cacheContext.save() } catch { report("Cache rebuild failed", error) return } let stubIDs = await tombstones.listStubIDs() for path in await listRecordPaths(in: store) { if stubIDs.contains(DocumentFileStore.documentID(fromRelativePath: path)) { try? await store.remove(at: Self.dataPath(path)) try? await store.remove(at: Self.dataPath(Note.audioRelativePath(forJSONPath: path))) continue } await importRecord(at: path, using: store) } lastSyncError = nil } /// Records-relative paths of all live record files (placeholder-aware — /// includes evicted iCloud-only files). private func listRecordPaths(in store: DocumentFileStore) async -> [String] { let prefix = Self.recordsDirectory + "/" return await store.list(inSubdirectory: Self.recordsDirectory) .map { String($0.dropFirst(prefix.count)) } .sorted() } // MARK: - Restore support /// Stops the metadata observers so a backup restore's bulk file /// replacement isn't observed as live sync events. func suspendForRestore() { stopMonitoring() } /// Restarts fresh observers after a restore. The new NSMetadataQuery /// re-baselines its known files on gather, so the restored tree does not /// replay as a flood of add/remove events. func resumeAfterRestore() { startMonitoring() } // MARK: - Maintenance private func performLaunchMaintenance() { Task(priority: .utility) { [tombstones] in _ = try? await tombstones?.prune() } } // MARK: - Schema migration /// Read-time upgrade for documents behind the current schema. Wired into /// both read paths so a schema bump migrates instead of silently skipping. /// Write-back happens lazily on the next save — never from live monitor /// events. /// /// v2 → v3: transcripts moved from `payload.text` into `audio.attempts`. /// A v2 voice note whose transcription completed holds its transcript (or /// the transcript merged with user typing — indistinguishable by then) in /// `text`; move that whole text into the first attempt so the v3 invariant /// "text is user writing only" holds. Confidence was never recorded ⇒ nil. /// Internal (not private) so the migration policy is unit-testable. func migrateIfNeeded(_ note: Note) -> Note { guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note } var migrated = note if note.meta.schemaVersion < 3, var audio = migrated.payload.audio, audio.transcriptionStatus == .done, audio.attempts.isEmpty, !migrated.payload.text.isEmpty { audio.attempts = [Note.TranscriptionAttempt( text: migrated.payload.text, localeID: audio.transcriptionLocaleID ?? "", confidence: nil, transcribedAt: audio.transcribedAt ?? migrated.meta.modifiedAt, languageWasForced: false )] migrated.payload.text = "" migrated.payload.audio = audio } migrated.meta.schemaVersion = Note.currentSchemaVersion return migrated } }