// SyncedSpeechHistory.swift // OSGKeyboard · Shared // // iCloud KVS payload for speech history. Tombstones and `clearedAt` // propagate single-entry deletes and "clear all" across devices. import Foundation public struct SyncedSpeechHistory: Codable, Equatable, Sendable { public static let schemaVersion = 3 public static let kvsKey = "speechHistory.v2" public static let legacyKVSKey = "speechHistory.v1" public static let maxEntries = 300 /// Tombstones older than this window may be pruned during merge. /// Tombstones guard against deleted entries "resurrecting" when a /// long-offline device rejoins and re-merges them. A short wall-clock /// retention re-opened that window after only 90 days; a year keeps the /// window closed for any realistically dormant device while staying tiny /// on the wire (a tombstone is ~60 bytes of JSON), and the count cap /// bounds the worst case regardless of clock. public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60 /// Hard cap independent of wall clock — the oldest tombstones are /// dropped first once exceeded. public static let maxTombstones = 500 public var schemaVersion: Int public var updatedAt: Date public var entries: [SpeechHistoryEntry] /// Entry IDs deleted on any device, with deletion timestamps. public var deletedEntryIDs: [UUID: Date] /// Recent idempotency keys for keyboard-originated history mutations. public var appliedMutationIDs: [UUID] /// When set, entries created at or before this instant are excluded. public var clearedAt: Date? public init( schemaVersion: Int = Self.schemaVersion, updatedAt: Date = Date(), entries: [SpeechHistoryEntry] = [], deletedEntryIDs: [UUID: Date] = [:], appliedMutationIDs: [UUID] = [], clearedAt: Date? = nil ) { self.schemaVersion = schemaVersion self.updatedAt = updatedAt self.entries = entries self.deletedEntryIDs = deletedEntryIDs self.appliedMutationIDs = appliedMutationIDs self.clearedAt = clearedAt } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 updatedAt = try container.decode(Date.self, forKey: .updatedAt) entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? [] if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) { deletedEntryIDs = map } else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) { let stamp = Date() deletedEntryIDs = Dictionary(uniqueKeysWithValues: legacyIDs.map { ($0, stamp) }) } else { deletedEntryIDs = [:] } appliedMutationIDs = try container.decodeIfPresent( [UUID].self, forKey: .appliedMutationIDs ) ?? [] clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt) } public static let empty = SyncedSpeechHistory(updatedAt: .distantPast) /// Union entries by id. Higher revision wins; timestamps break legacy ties. public static func merge(local: SyncedSpeechHistory, remote: SyncedSpeechHistory) -> SyncedSpeechHistory { let clearedAt = later(of: local.clearedAt, and: remote.clearedAt) var deletedIDs = local.deletedEntryIDs for (id, date) in remote.deletedEntryIDs { if let existing = deletedIDs[id] { deletedIDs[id] = max(existing, date) } else { deletedIDs[id] = date } } deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt) let appliedMutationIDs = Array( Set(local.appliedMutationIDs + remote.appliedMutationIDs) .sorted { $0.uuidString < $1.uuidString } .prefix(256) ) var byID: [UUID: SpeechHistoryEntry] = [:] for entry in local.entries + remote.entries { if deletedIDs[entry.id] != nil { continue } if let clearedAt, entry.createdAt <= clearedAt { continue } if let existing = byID[entry.id] { byID[entry.id] = preferred(entry, over: existing) } else { byID[entry.id] = entry } } var entries = Array(byID.values).sorted { $0.createdAt > $1.createdAt } if entries.count > maxEntries { entries = Array(entries.prefix(maxEntries)) } return SyncedSpeechHistory( updatedAt: max(local.updatedAt, remote.updatedAt), entries: entries, deletedEntryIDs: deletedIDs, appliedMutationIDs: appliedMutationIDs, clearedAt: clearedAt ) } /// Trim to the newest `maxEntries` rows (call after local-only appends). public mutating func trimEntries() { guard entries.count > Self.maxEntries else { return } entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries)) updatedAt = Date() } public mutating func pruneTombstonesIfNeeded() { deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt) } private static func pruneTombstones( _ tombstones: [UUID: Date], clearedAt: Date? ) -> [UUID: Date] { let cutoff = Date().addingTimeInterval(-tombstoneRetention) var kept = tombstones.filter { _, deletedAt in if deletedAt < cutoff { return false } if let clearedAt, deletedAt <= clearedAt { return false } return true } // Enforce the count cap that makes the 365-day retention safe on the // KVS byte budget: keep the NEWEST tombstones (dropping an old one // early only re-opens the resurrection window for that one entry). if kept.count > maxTombstones { let newest = kept.sorted { $0.value > $1.value }.prefix(maxTombstones) kept = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0.value) }) } return kept } private static func later(of lhs: Date?, and rhs: Date?) -> Date? { switch (lhs, rhs) { case let (left?, right?): return max(left, right) case (nil, let right?): return right case (let left?, nil): return left case (nil, nil): return nil } } private static func preferred( _ candidate: SpeechHistoryEntry, over existing: SpeechHistoryEntry ) -> SpeechHistoryEntry { if candidate.revision != existing.revision { return candidate.revision > existing.revision ? candidate : existing } if candidate.modifiedAt != existing.modifiedAt { return candidate.modifiedAt > existing.modifiedAt ? candidate : existing } return candidate.createdAt >= existing.createdAt ? candidate : existing } } extension SyncedSpeechHistory { mutating func recordClearAll(at date: Date = Date()) { entries.removeAll() clearedAt = date } }