Schema v3: transcription attempts accumulate on AudioInfo (text, locale, confidence, forced flag) — latest attempt is displayed, payload.text is purely user writing, v2 notes migrate at read time. Editor shows the transcript in its own live-updating section with insert-into-note; search matches transcripts weighted by confidence. Watch recordings are now deleted only on the phone's durable-ingest ack (transferUserInfo), closing every phone-side loss window; failed sidecar writes unstage instead of orphaning, and unreadable inbox recordings are surfaced in logs. Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
789 lines
33 KiB
Swift
789 lines
33 KiB
Swift
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/<ULID>.json
|
|
/// Stubs/YYYY/MM/<ULID>.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<Void, Never>?
|
|
private var stubMonitorTask: Task<Void, Never>?
|
|
|
|
/// 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 `<ULID>.m4a` plus a `<ULID>.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 `<ULID>.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 `<ULID>.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" {
|
|
// `<ULID>.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 `<ULID>.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<String>) {
|
|
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<NoteEntity>())) ?? 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<NoteEntity>()
|
|
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
|
|
}
|
|
}
|