learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLSyncEngine.swift
saravanakumardb1 78000cdf6a feat(swift-sdk): add ByteLystPlatformSDK — shared Swift package for all iOS/watchOS/macOS apps
Extracts duplicated platform integration code from ChronoMind + LysnrAI into a
single Swift Package. Eliminates ~1,100+ lines of copied code per product app.

Components:
- BLPlatformConfig — product-specific configuration (productId, baseURL, bundleId)
- BLPlatformClient — generic HTTP client with auth injection, x-request-id, timeout
- BLKeychain — Keychain CRUD for secure token storage
- BLTelemetryClient — telemetry queue + batch flush (matches @bytelyst/telemetry-client)
- BLAuthClient — full auth operations (matches @bytelyst/auth-client)
- BLFeatureFlagClient — feature flag polling from platform-service /flags/poll
- BLSyncEngine — generic offline-first sync with delta pull + batch push

Platforms: iOS 17+, watchOS 10+, macOS 14+
2026-02-28 22:12:20 -08:00

241 lines
7.5 KiB
Swift

// 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<T> {
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<Adapter: BLSyncAdapter> {
private let config: BLPlatformConfig
private let client: BLPlatformClient
private let adapter: Adapter
private let storagePrefix: String
private var syncTask: Task<Void, Never>?
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<Adapter.SyncItem> {
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")
}
}
}