[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:
Daniel Arantes Loverde
2026-09-16 09:23:57 -03:00
parent d067791930
commit 2ba487a6e8
47 changed files with 4264 additions and 113 deletions

View 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)]) }
}
}