Files
purchases-ios/Sources/LocalRules/DimensionResolver.swift
T
Antonio PallaresandCursor a9be1ba1d5 Compare object keys by UTF-16 code unit in the rules engine (#7562)
* Compare strings by UTF-16 code unit in the rules engine

A JS string is a sequence of UTF-16 code units and every string operation
works on those units. Swift's String compares canonical equivalence over
Unicode scalars, so <, <=, >, >=, ==, ===, in and rc.entries key ordering
all had cases where a predicate evaluated one way on iOS and the other in
JS and Kotlin.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the new fixture descriptions platform-neutral

The fixture files are byte-identical across the iOS and Android engines, so
a description that explains the contract in terms of one host language's
string comparison does not read correctly in the other repository.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Name the characters under test in the UTF-16 fixture descriptions

The predicates spell these strings as \uXXXX escapes so the exact code
units are unambiguous, which leaves a reader unable to tell which
character a fixture is about. Each description now shows the glyph and
its code point, and spells out the surrogate pair where one is the
point of the case.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route rc.sortBy string keys through jsStringPrecedes

The operator open-coded the UTF-16 comparison because the shared helper
did not exist yet when it landed. It does now, and the rationale lives
in the helper's doc, so the operator no longer repeats it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare object keys by UTF-16 code unit

A Swift dictionary keys by canonical equivalence, so é (U+00E9) and e
followed by a combining acute (U+0301) were one key: an object holding
both spellings kept only one, and a var path spelled either way resolved
against the other. JS and Kotlin keep the two apart, so a rule reading a
non-ASCII key could answer differently per platform.

Value.object now stores an ObjectValue, whose keys compare and hash by
code unit, and the JSON parser iterates the NSDictionary rather than
casting to [String: Any], which merges the keys before the engine sees
them.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fold the ObjectValue.filter condition into a where clause

SwiftLint's for_where rule, and it reads better.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:19:16 +02:00

170 lines
6.1 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// DimensionResolver.swift
//
// Created by Rick van der Linden on 7/28/26.
//
import Foundation
struct DimensionSnapshot: Equatable, Sendable {
let values: RulesEngine.ObjectValue
let evaluationDate: Date
}
enum DimensionResolutionError: Error, Equatable, Sendable {
case providerFailed(providerName: String, message: String)
case conflictingValue(path: String)
case appUserChanged
}
/// Builds an immutable, point-in-time root scope for local rule evaluation.
///
/// Providers can suspend while collecting values. The current app user ID is therefore read before collection and
/// verified after every provider finishes, preventing a snapshot from combining values belonging to two app users.
/// Identity changes also advance the remote-config generation, allowing checkpoint resolution to retry against the
/// new app user instead of evaluating the invalid snapshot.
struct DimensionResolver: Sendable {
private let dimensionProviders: [any DimensionProvider]
private let currentAppUserIDProvider: @Sendable () -> String
private let dateProvider: DateProvider
/// Creates a resolver from injected providers, the current app user, and a clock.
init(
dimensionProviders: [any DimensionProvider],
currentAppUserIDProvider: @escaping @Sendable () -> String,
dateProvider: DateProvider = DateProvider()
) {
self.dimensionProviders = dimensionProviders
self.currentAppUserIDProvider = currentAppUserIDProvider
self.dateProvider = dateProvider
}
/// Collects each provider once and merges its values into the canonical root scope.
///
/// `custom` remains a nested reserved object. Every other value is flat.
func snapshot(
customVariables: [String: DimensionValue] = [:]
) async throws -> DimensionSnapshot {
let appUserID = self.currentAppUserIDProvider()
let date = self.dateProvider.now()
var values: RulesEngine.ObjectValue = [
Self.evaluatedAtKey: .int(Int64(date.timeIntervalSince1970 * 1_000))
]
for provider in self.dimensionProviders {
try Task.checkCancellation()
let providerValues: [String: DimensionValue]
do {
providerValues = try await provider.dimensions(at: date)
} catch let error as CancellationError {
throw error
} catch {
throw DimensionResolutionError.providerFailed(
providerName: provider.name,
message: String(describing: error)
)
}
for (name, value) in DimensionValueConverter.convert(
providerValues,
parentPath: provider.name
) {
guard !Self.reservedRootKeys.contains(name), values[name] == nil else {
throw DimensionResolutionError.conflictingValue(
path: name
)
}
values[name] = value
}
}
Self.addPerEvaluationValues(
CustomVariableKeyValidator.validateAndFilter(customVariables),
root: Self.customKey,
to: &values
)
try Task.checkCancellation()
guard self.currentAppUserIDProvider() == appUserID else {
throw DimensionResolutionError.appUserChanged
}
return DimensionSnapshot(values: values, evaluationDate: date)
}
private static func addPerEvaluationValues(
_ dimensions: [String: DimensionValue],
root: String,
to values: inout RulesEngine.ObjectValue
) {
let converted = DimensionValueConverter.convert(dimensions, parentPath: root)
if !converted.isEmpty {
values[root] = .object(converted)
}
}
private static let evaluatedAtKey = "evaluated_at"
private static let customKey = "custom"
private static let reservedRootKeys: Set<String> = [Self.evaluatedAtKey, Self.customKey]
}
private enum DimensionValueConverter {
static func convert(
_ dimensions: [String: DimensionValue],
parentPath: String
) -> RulesEngine.ObjectValue {
return dimensions.reduce(into: RulesEngine.ObjectValue()) { result, dimension in
let (name, value) = dimension
guard Self.isValidName(name) else {
Logger.warn(Strings.localRules.invalidDimensionName(name, parentPath: parentPath))
return
}
guard let converted = Self.convert(value, path: "\(parentPath).\(name)") else {
return
}
result[name] = converted
}
}
/// Converts a provider value into its RulesEngine equivalent while filtering invalid nested names.
private static func convert(_ dimension: DimensionValue, path: String) -> RulesEngine.Value? {
switch dimension {
case .null: return .null
case .string(let value): return .string(value)
case .bool(let value): return .bool(value)
case .int(let value): return .int(value)
case .double(let value): return .float(value)
case .date(let value): return .int(Int64(value.timeIntervalSince1970 * 1_000))
case .object(let value):
let converted = Self.convert(value, parentPath: path)
return converted.isEmpty ? nil : .object(converted)
case .objectList(let values):
return .array(values.enumerated().compactMap { index, value in
let converted = Self.convert(value, parentPath: "\(path).\(index)")
return converted.isEmpty ? nil : .object(converted)
})
}
}
private static func isValidName(_ name: String) -> Bool {
return name.notEmptyOrWhitespaces != nil && !name.contains(Self.pathSeparator)
}
private static let pathSeparator: Character = "."
}