[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:
46
Sources/LCFeatureControl/FeatureControlCache.swift
Normal file
46
Sources/LCFeatureControl/FeatureControlCache.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// Identifies a cached evaluation — FC-060 §5: "environment + subjectId + keys(sorted) +
|
||||
/// platform + appVersion". `sortedKeys` means caller `keys` order never affects hits.
|
||||
struct FeatureControlCacheKey: Hashable, Sendable {
|
||||
let environment: String
|
||||
let subjectId: String
|
||||
let sortedKeys: [String]
|
||||
let platform: String?
|
||||
let appVersion: String?
|
||||
}
|
||||
|
||||
/// In-memory-only TTL cache (no disk persistence — see SDD §2 Non-Goals). The clock is
|
||||
/// injectable so tests can force expiry deterministically instead of sleeping.
|
||||
actor FeatureControlCache {
|
||||
private struct Entry {
|
||||
let snapshot: FeatureControlSnapshot
|
||||
let storedAt: Date
|
||||
let ttl: TimeInterval
|
||||
}
|
||||
|
||||
private var entries: [FeatureControlCacheKey: Entry] = [:]
|
||||
private let now: @Sendable () -> Date
|
||||
|
||||
init(now: @escaping @Sendable () -> Date = { Date() }) {
|
||||
self.now = now
|
||||
}
|
||||
|
||||
/// `allowStale: true` returns an expired entry rather than `nil` — used by the
|
||||
/// safe-degrade fallback path (SDD §4.4/§4.5).
|
||||
func get(_ key: FeatureControlCacheKey, allowStale: Bool) -> FeatureControlSnapshot? {
|
||||
guard let entry = entries[key] else { return nil }
|
||||
let age = now().timeIntervalSince(entry.storedAt)
|
||||
if age <= entry.ttl { return entry.snapshot }
|
||||
return allowStale ? entry.snapshot : nil
|
||||
}
|
||||
|
||||
/// Always overwrites — a fresh fetch's `configVersion` simply replaces whatever was there.
|
||||
func set(_ key: FeatureControlCacheKey, snapshot: FeatureControlSnapshot, ttl: TimeInterval) {
|
||||
entries[key] = Entry(snapshot: snapshot, storedAt: now(), ttl: ttl)
|
||||
}
|
||||
|
||||
func invalidateAll() {
|
||||
entries.removeAll()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user