[sub-spm-optional-products] Add LCECryptoKit and LCFeatureControl as optional sub-SPM products
LCEssentials installs standalone; each sub target-depends on it so linking a sub's product always pulls LCEssentials in too, without the consumer having to declare it separately. - LCECryptoKit: internalized from the remote LCECryptoKitBinary git dependency (embedded token URL removed) into a local binaryTarget vendoring Frameworks/LCECryptoKit.xcframework. LCECryptoKitManager moved out of LCEssentials core into its own LCECryptoKitManager target/product; the no-op fallback for when the binary wasn't linked is gone (breaking change for existing consumers, see decisions/2026-09-15-sub-spm-optional-products.md). - LCFeatureControl: new product wrapping Atomenta's Feature Control API (flag evaluation with TTL cache + safe-degrade fallback to defaults, notifications inbox, batched exposure telemetry). 45 new tests.
This commit is contained in:
123
Sources/LCFeatureControl/FeatureControlManager.swift
Normal file
123
Sources/LCFeatureControl/FeatureControlManager.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
import LCEssentials
|
||||
|
||||
/// Protocol seam for DI — ViewModels/Interactors depend on this, never on the
|
||||
/// concrete actor, so a mock can stand in for tests.
|
||||
public protocol FeatureControlEvaluating: Sendable {
|
||||
/// Never throws. Fresh fetch → stale cache → app-supplied `defaults`, in that
|
||||
/// order (FC-060 §4 "nunca bloquear fluxo crítico"). Use this everywhere a
|
||||
/// flag gates real product behaviour.
|
||||
func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot
|
||||
/// Same fetch, but surfaces the real failure. For admin/debug tooling only.
|
||||
func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot
|
||||
/// Drops every cached entry. Call on user/store change, or app foreground if desired —
|
||||
/// this type does not observe app lifecycle itself (SDD §2 Non-Goals).
|
||||
func invalidateCache() async
|
||||
}
|
||||
|
||||
public actor FeatureControlManager: FeatureControlEvaluating {
|
||||
private let configuration: FeatureControlConfiguration
|
||||
private let api: API
|
||||
private let defaults: [String: FeatureControlFlag]
|
||||
private let cache: FeatureControlCache
|
||||
private var exposureBuffer: [FeatureControlExposureEvent] = []
|
||||
|
||||
/// `configVersion` sentinel returned when no network response and no cache exist —
|
||||
/// distinguishes "never evaluated" from any real server value (server versions are ≥ 0).
|
||||
public static let unresolvedConfigVersion = -1
|
||||
|
||||
public init(configuration: FeatureControlConfiguration,
|
||||
api: API = .shared,
|
||||
defaults: [String: FeatureControlFlag] = [:],
|
||||
now: @escaping @Sendable () -> Date = { Date() }) {
|
||||
self.configuration = configuration
|
||||
self.api = api
|
||||
self.defaults = defaults
|
||||
self.cache = FeatureControlCache(now: now)
|
||||
}
|
||||
|
||||
public func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot {
|
||||
do {
|
||||
return try await evaluateOrThrow(keys: keys, context: context)
|
||||
} catch {
|
||||
let key = cacheKey(keys: keys, context: context)
|
||||
if let stale = await cache.get(key, allowStale: true) { return stale }
|
||||
return FeatureControlSnapshot(evaluatedAt: Date(),
|
||||
configVersion: Self.unresolvedConfigVersion,
|
||||
flags: defaults)
|
||||
}
|
||||
}
|
||||
|
||||
public func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot {
|
||||
let key = cacheKey(keys: keys, context: context)
|
||||
if let fresh = await cache.get(key, allowStale: false) { return fresh }
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
|
||||
let body = jsonBody(FeatureControlEvaluateRequestBody(
|
||||
environment: configuration.environment, keys: keys, context: context))
|
||||
|
||||
do {
|
||||
let envelope: FeatureControlEvaluateEnvelope = try await api.request(
|
||||
url: configuration.baseURL + configuration.evaluatePath,
|
||||
method: .post,
|
||||
body: body,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
await cache.set(key, snapshot: envelope.result, ttl: configuration.cacheTTL)
|
||||
return envelope.result
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func invalidateCache() async {
|
||||
await cache.invalidateAll()
|
||||
}
|
||||
|
||||
private func cacheKey(keys: [String], context: FeatureControlContext) -> FeatureControlCacheKey {
|
||||
FeatureControlCacheKey(environment: configuration.environment,
|
||||
subjectId: context.subjectId,
|
||||
sortedKeys: keys.sorted(),
|
||||
platform: context.platform,
|
||||
appVersion: context.appVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exposure telemetry
|
||||
|
||||
extension FeatureControlManager {
|
||||
/// Buffers locally. Nothing is sent until `flushExposures()` is called.
|
||||
public func recordExposure(_ event: FeatureControlExposureEvent) {
|
||||
exposureBuffer.append(event)
|
||||
}
|
||||
|
||||
/// Best-effort — batches of ≤100 (server `maxItems: 100`); a failed batch is
|
||||
/// dropped, never retried indefinitely (not a critical-path operation).
|
||||
public func flushExposures() async {
|
||||
guard !exposureBuffer.isEmpty else { return }
|
||||
let events = exposureBuffer
|
||||
exposureBuffer.removeAll()
|
||||
|
||||
for batch in events.lce_featureControlChunked(into: 100) {
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
let body = jsonBody(FeatureControlExposureBatchBody(events: batch))
|
||||
_ = try? await api.request(
|
||||
url: configuration.baseURL + configuration.telemetryPath,
|
||||
method: .post,
|
||||
body: body,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
) as FeatureControlExposureBatchEnvelope
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func lce_featureControlChunked(into size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user