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+
241 lines
7.5 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|