// ── Sync Engine ────────────────────────────────────────────── // Generic offline-first sync engine for ByteLyst iOS apps. // Provides: offline queue, periodic sync, delta pull, batch push. // Product apps supply a SyncAdapter with their DTO types and endpoints. // // This extracts the generic parts of ChronoMind's PlatformSyncManager // so every product app gets offline sync without reimplementing the // queue, timer, error handling, and conflict resolution plumbing. import Foundation // MARK: - Sync Adapter Protocol /// Product apps implement this protocol to define their sync endpoints and DTO types. public protocol BLSyncAdapter { associatedtype SyncItem: Codable /// Pull remote changes since the given date. Return empty array if no changes. func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [SyncItem] /// Push a batch of offline queue items. Return IDs of successfully synced items. func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult } // MARK: - Offline Queue Item /// A queued change waiting to be synced. public struct BLOfflineQueueItem: Codable { public let id: String public let action: BLSyncAction public let payload: Data? // JSON-encoded product-specific DTO public let enqueuedAt: Date public init(id: String, action: BLSyncAction, payload: Data?, enqueuedAt: Date = Date()) { self.id = id self.action = action self.payload = payload self.enqueuedAt = enqueuedAt } } public enum BLSyncAction: String, Codable { case create case update case delete } // MARK: - Sync Results public struct BLSyncResult { public let pulled: [T] public let syncedIds: [String] public let conflicts: [BLSyncConflict] public let error: String? public init(pulled: [T], syncedIds: [String] = [], conflicts: [BLSyncConflict] = [], error: String? = nil) { self.pulled = pulled self.syncedIds = syncedIds self.conflicts = conflicts self.error = error } } public struct BLSyncConflict: Codable { public let id: String public let serverVersion: Int public init(id: String, serverVersion: Int) { self.id = id self.serverVersion = serverVersion } } public struct BLBatchResult: Codable { public let synced: [String] public let conflicts: [BLSyncConflict] public let errors: [String] public init(synced: [String] = [], conflicts: [BLSyncConflict] = [], errors: [String] = []) { self.synced = synced self.conflicts = conflicts self.errors = errors } } // MARK: - Sync Engine /// Generic sync engine. Product apps create one instance with their SyncAdapter. public final class BLSyncEngine { private let config: BLPlatformConfig private let client: BLPlatformClient private let adapter: Adapter private let storagePrefix: String private var syncTask: Task? private let syncIntervalSec: TimeInterval // MARK: - State public private(set) var isSyncing = false public private(set) var lastSyncDate: Date? public private(set) var pendingChanges: Int = 0 public private(set) var lastError: String? public var syncEnabled: Bool { didSet { UserDefaults.standard.set(syncEnabled, forKey: "\(storagePrefix)-sync-enabled") if syncEnabled { schedulePeriodicSync() } else { cancelPeriodicSync() } } } /// Called when sync state changes (for UI binding). public var onStateChanged: (() -> Void)? public init( config: BLPlatformConfig, client: BLPlatformClient, adapter: Adapter, syncIntervalSec: TimeInterval = 60 ) { self.config = config self.client = client self.adapter = adapter self.syncIntervalSec = syncIntervalSec self.storagePrefix = config.productId syncEnabled = UserDefaults.standard.bool(forKey: "\(storagePrefix)-sync-enabled") if let date = UserDefaults.standard.object(forKey: "\(storagePrefix)-last-sync") as? Date { lastSyncDate = date } pendingChanges = loadQueueItems().count if syncEnabled { schedulePeriodicSync() } } // MARK: - Sync /// Full delta sync: pull remote changes, push offline queue. public func sync() async -> BLSyncResult { guard syncEnabled, client.authToken != nil else { return BLSyncResult(pulled: [], error: "Not authenticated or sync disabled") } isSyncing = true lastError = nil onStateChanged?() defer { isSyncing = false onStateChanged?() } do { let pulled = try await adapter.pullDelta(since: lastSyncDate, client: client) let batchResult = try await pushOfflineQueue() lastSyncDate = Date() UserDefaults.standard.set(lastSyncDate, forKey: "\(storagePrefix)-last-sync") return BLSyncResult( pulled: pulled, syncedIds: batchResult.synced, conflicts: batchResult.conflicts, error: nil ) } catch { lastError = error.localizedDescription return BLSyncResult(pulled: [], error: error.localizedDescription) } } // MARK: - Offline Queue /// Enqueue a change for later sync. public func enqueueChange(id: String, action: BLSyncAction, payload: Data?) { var queue = loadQueueItems() queue.removeAll { $0.id == id } queue.append(BLOfflineQueueItem(id: id, action: action, payload: payload)) saveQueue(queue) pendingChanges = queue.count onStateChanged?() } /// Enqueue a delete. public func enqueueDelete(id: String) { enqueueChange(id: id, action: .delete, payload: nil) } // MARK: - Private private func pushOfflineQueue() async throws -> BLBatchResult { let queue = loadQueueItems() guard !queue.isEmpty else { return BLBatchResult() } let result = try await adapter.pushBatch(queue, client: client) // Clear synced items var remaining = loadQueueItems() remaining.removeAll { result.synced.contains($0.id) } saveQueue(remaining) pendingChanges = remaining.count return result } private func schedulePeriodicSync() { cancelPeriodicSync() syncTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: UInt64((self?.syncIntervalSec ?? 60) * 1_000_000_000)) guard let self, self.syncEnabled else { break } _ = await self.sync() } } } private func cancelPeriodicSync() { syncTask?.cancel() syncTask = nil } // MARK: - Queue Persistence private func loadQueueItems() -> [BLOfflineQueueItem] { guard let data = UserDefaults.standard.data(forKey: "\(storagePrefix)-offline-queue") else { return [] } return (try? JSONDecoder().decode([BLOfflineQueueItem].self, from: data)) ?? [] } private func saveQueue(_ queue: [BLOfflineQueueItem]) { if let data = try? JSONEncoder().encode(queue) { UserDefaults.standard.set(data, forKey: "\(storagePrefix)-offline-queue") } } }