mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* Ingest the checkpoints config topic Adds the `checkpoints` topic to the remote-config read facade, parsing models for a checkpoint's ordered rules, and a CheckpointsConfigProvider to read them. Rules are parsed only, not evaluated. Items are keyed by checkpoint name, so a checkpoint resolves with a single blobData read: no topic-index scan, and blobData already resolves a blob whether its bytes arrived inlined with the config response or have to be downloaded, and already invalidates on an identity change. This matches the split WorkflowsConfigProvider uses, where an item's content is metadata and its blob is the payload. Schema follows the "Storing and serving checkpoint rules" proposal: rules arrive already in priority order, and a rule carries an audience reference rather than an inline predicate (the audience's own predicate is expected in a separate topic). Parsing is tolerant, since the proposal notes the schema may still shift before V1: unknown fields are ignored and a malformed rule is skipped rather than failing the checkpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Add the checkpoints files to RevenueCat.xcodeproj Danger flagged the legacy project as out of sync: the new files were added via Tuist/SPM, which doesn't touch it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Prune obvious comments Drops comments restating the code, the Foundation snake_case explanation (the keys are literal in the code and pinned by a test), and the AnyObject constraint on CheckpointsConfigProviderType, which nothing needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Fix the topic name, require an audience, drop the frequency cap The backend serves this topic as `checkpoint_rules`, not `checkpoints` (khepri #23594 registers `TopicName('checkpoint_rules')`), so we were reading a topic that doesn't exist. An absent audience no longer means "targets everyone": the rule is skipped instead, matching `audience_id NOT NULL` in the schema. It's typed `Int` because the publisher serves `rule.audience_id`, a BigInteger. Frequency capping isn't used yet, so parsing it only adds ways to fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Decode checkpoint rules through the topic manager's typed helper Follows Rick's review: the models are Decodable and read through blobData(for:itemKey:as:) instead of hand-parsing AnyDecodable, with the tolerance kept in the Codable implementation. Two behaviour changes fall out of using JSONDecoder.default. Its date strategy throws on an unparseable date, so a schedule bound that's present but invalid now drops the whole rule instead of reading as nil, which had widened the window (Bugbot and vegaro both flagged this). And a rule that fails to decode for any reason is skipped by SkippableRule rather than failing its checkpoint. CheckpointRuleSet no longer carries `identifier`: it's the topic item key, which the caller already passes in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Type the audience reference as a public id khepri now resolves the rule's internal audience and workflow ids to their rc_public_ids before serving them (khepri #23594), so `audience` is a string like `workflow_id`, not the BigInteger the first draft of that publisher sent. That PR also emits UTC-marked timestamps and only publishes active or scheduled rules, so the schedule parsing and the fail-closed date handling here now line up with what's served. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Drop the schedule, it isn't V1 Same reasoning as the frequency cap: parsing a field nothing reads only adds ways to fail. Note for whoever wires up evaluation: khepri publishes rules in `scheduled` state as well as `active`, so the window has to be parsed and honoured then, or a rule will fire before its start date. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Drop rules with an empty audience or workflow reference The synthesized Decodable accepted an empty string where the hand-written parser had treated empty the same as missing, so those rules would have reached workflow resolution instead of being dropped at ingest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Pin the checkpoints topic wire name The provider doc still said `checkpoints` after the rename, and no test pinned the literal: every fixture derives the key from the enum, so a wrong wire name would read as an absent topic forever without failing anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN * Read the audience as `audience_id` and drop the checkpoint id Both from vegaro's review, matching what Android is doing: the backend is renaming the rule's `audience` key to `audience_id`, which convertFromSnakeCase maps for free, and the checkpoint's own id is unused and easy to confuse with the item key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
101 lines
2.9 KiB
Swift
101 lines
2.9 KiB
Swift
//
|
|
// CheckpointRules.swift
|
|
// RevenueCat
|
|
//
|
|
// Created by Facundo Menzella.
|
|
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
|
|
|
|
import Foundation
|
|
|
|
/// One checkpoint's rules, which decide whether it resolves to a workflow. Parsed only, not evaluated.
|
|
struct CheckpointRuleSet: Equatable, Sendable {
|
|
|
|
/// Served in priority order, first match wins. Never re-sorted here.
|
|
let rules: [CheckpointRule]
|
|
|
|
init(rules: [CheckpointRule]) {
|
|
self.rules = rules
|
|
}
|
|
|
|
}
|
|
|
|
struct CheckpointRule: Equatable, Sendable {
|
|
|
|
let id: String?
|
|
/// Reference to the targeted audience, whose predicate arrives in a separate topic. Required, so a rule
|
|
/// can never end up targeting everyone by omission.
|
|
let audienceId: String
|
|
let workflowId: String
|
|
|
|
init(id: String? = nil, audienceId: String, workflowId: String) {
|
|
self.id = id
|
|
self.audienceId = audienceId
|
|
self.workflowId = workflowId
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Decodable
|
|
|
|
extension CheckpointRuleSet: Decodable {
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case rules
|
|
}
|
|
|
|
/// A rule the SDK can't make sense of is skipped rather than failing the checkpoint, so one bad rule
|
|
/// can't take out the rest.
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
self.rules = (try container.decodeIfPresent([SkippableRule].self, forKey: .rules) ?? [])
|
|
.compactMap(\.rule)
|
|
}
|
|
|
|
}
|
|
|
|
extension CheckpointRule: Decodable {
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case audienceId
|
|
case workflowId
|
|
}
|
|
|
|
/// An empty reference is treated the same as a missing one: it can't resolve to anything, so the rule is
|
|
/// better dropped at ingest than left to fail later.
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
self.id = try container.decodeIfPresent(String.self, forKey: .id)
|
|
self.audienceId = try container.decode(String.self, forKey: .audienceId)
|
|
self.workflowId = try container.decode(String.self, forKey: .workflowId)
|
|
|
|
for (key, value) in [(CodingKeys.audienceId, self.audienceId), (.workflowId, self.workflowId)]
|
|
where value.isEmpty {
|
|
throw DecodingError.dataCorruptedError(
|
|
forKey: key,
|
|
in: container,
|
|
debugDescription: "'\(key.rawValue)' is empty"
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// Turns a rule that fails to decode into `nil` instead of failing its enclosing array.
|
|
private struct SkippableRule: Decodable {
|
|
|
|
let rule: CheckpointRule?
|
|
|
|
init(from decoder: Decoder) throws {
|
|
do {
|
|
self.rule = try CheckpointRule(from: decoder)
|
|
} catch {
|
|
Logger.warn(Strings.remoteConfig.checkpointRuleSkipped(reason: "\(error)"))
|
|
self.rule = nil
|
|
}
|
|
}
|
|
|
|
}
|