RemoteConfigStore

0.6.1

AltiAntonov/RemoteConfigStore

What's New

0.6.1

2026-05-04T19:13:31Z

Summary

Structured payload example follow-up for RemoteConfigStore.

Added

  • Dedicated Structured Payloads scenario in the example app.
  • Example coverage for valid Decodable model reads from nested config.
  • Example coverage for malformed payload decoding failures.
  • Raw nested snapshot inspection in the example app.

Changed

  • README now links structured payload use cases to the example scenario.
  • DocC structured decoding article now references the example scenario.
  • README installation now points to 0.6.1.

Verification

  • swift test
  • xcodebuild -scheme RemoteConfigStoreExample -project Example/RemoteConfigStore/RemoteConfigStore.xcodeproj -destination 'generic/platform=iOS Simulator' build
  • GitHub Actions: Package Tests and Example Build passed.

What's Changed

Full Changelog: 0.6.0...0.6.1

RemoteConfigStore

Offline-first remote config caching with TTL, stale fallback, and typed keys.

Swift version compatibility Platform compatibility MIT License Swift workflow

Features · Installation · Quick Start · Structured Values · When To Use · Good Fits · Weaker Fits · Read Policies · Freshness Model · Errors · Example App · Documentation · Testing · Example Scenarios

Features

  • memory and disk-backed cache layers
  • TTL-based freshness
  • optional stale fallback using maxStaleAge
  • injected fetcher protocol for remote loading
  • typed key access for primitive values
  • nested object, array, and null values
  • structured Decodable reads for consumer-defined config models
  • actor-backed store implementation for serialized state access
  • async update stream, lightweight update hook, and inspection state for refresh visibility

The public API is intentionally centered on:

  • RemoteConfigStore
  • RemoteConfigFetcher
  • RemoteConfigSnapshot
  • RemoteConfigRefreshResult
  • RemoteConfigUpdate
  • RemoteConfigStoreInspectionState
  • RemoteConfigKey
  • RemoteConfigValue
  • RemoteConfigDecodingError
  • ReadPolicy
  • RemoteConfigStoreError
  • Logger

Installation

Add RemoteConfigStore to your Swift Package Manager dependencies:

dependencies: [
    .package(url: "https://github.com/AltiAntonov/RemoteConfigStore.git", from: "0.6.1")
]

Then add the product to your target:

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "RemoteConfigStore", package: "RemoteConfigStore")
    ]
)

If you need to try unreleased changes, pin to a branch or revision explicitly.

Quick Start

import Foundation
import RemoteConfigStore

enum AppConfigKeys {
    static let newUI = RemoteConfigKey<Bool>("new_ui", defaultValue: false)
}

struct AppFetcher: RemoteConfigFetcher {
    func fetchSnapshot() async throws -> RemoteConfigSnapshot {
        RemoteConfigSnapshot(values: [
            "new_ui": .bool(true)
        ])
    }
}

let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let store = try RemoteConfigStore(
    fetcher: AppFetcher(),
    cacheDirectory: directory.appendingPathComponent("RemoteConfigStore"),
    ttl: 300,
    maxStaleAge: 3600
)

let enabled = try await store.value(for: AppConfigKeys.newUI, using: .immediate)

If you need to know whether a refresh actually changed the payload, use refreshResult():

switch try await store.refreshResult() {
case .updated(let snapshot):
    print("Config changed:", snapshot.values)
case .unchanged:
    print("Config payload is unchanged.")
}

You can also use the built-in HTTP path when your config comes from a JSON endpoint:

let store = try RemoteConfigStore(
    request: HTTPRemoteConfigRequest(
        url: URL(string: "https://example.com/remote-config.json")!,
        headers: ["Authorization": "Bearer token"],
        timeoutInterval: 8
    ),
    cacheDirectory: directory.appendingPathComponent("RemoteConfigStore"),
    ttl: 300
)

When the built-in HTTP fetcher receives ETag or Last-Modified headers, the store persists that validation metadata and uses it for later conditional refreshes.

To observe refresh activity, consume the update stream or pass a lightweight hook during initialization:

let updates = await store.updates()
Task {
    for await update in updates {
        print("Refresh event:", update.result)
    }
}

let state = try await store.inspectionState()
print("Freshness:", state.freshness as Any)

Structured Values

Use primitive typed keys for simple flags and tuning values. Use structured decoding when one remote config key owns a small nested object.

struct PaywallConfig: Decodable, Sendable {
    let title: String
    let enabled: Bool
    let tiers: [String]
}

let paywall = try await store.decodedValue(
    PaywallConfig.self,
    for: "paywall",
    using: .immediate
)

RemoteConfigSnapshot also supports structured decoding when you already have a snapshot:

let snapshot = try await store.cachedSnapshot()
let paywall = try snapshot.decodedValue(PaywallConfig.self, for: "paywall")

If the key is missing or the stored value cannot be decoded into the requested type, the call throws RemoteConfigDecodingError.

When To Use RemoteConfigStore

Use RemoteConfigStore when an app needs server-driven values but should still behave predictably when the network is slow, unavailable, or temporarily failing.

It is a strong fit for configuration that should be cached locally, refreshed deliberately, and read through a typed API instead of raw dictionaries spread across an app.

Good Fits

  • Feature flags and staged rollout controls Example: Feature Flags scenario
  • Runtime tuning values such as request timeouts, polling intervals, and rollout percentages Example: Feature Flags scenario
  • Remote text or copy that should remain available offline Example: Feature Flags scenario
  • Compact nested objects such as paywall copy, onboarding copy, or grouped operational settings Example: Structured Payloads scenario
  • Safety switches and operational config that benefit from stale fallback instead of hard failure Example soon
  • Apps that care about startup speed and want cache-first or stale-while-revalidate reads Example: Feature Flags scenario

Weaker Fits

  • Large documents that should be modeled as app content rather than operational config Example soon
  • Cases where config must always be fresh and stale data is never acceptable Example soon
  • Projects that need analytics-grade event collection or complex observability pipelines Example soon
  • Data that is really user content or secure secret material rather than app configuration Example soon

Read Policies

RemoteConfigStore currently supports three read policies:

Policy Fresh cache Stale but usable cache No usable cache Best fit
.immediate returns cache returns cache waits for refresh fast UI reads
.refreshBeforeReturning returns cache tries refresh first waits for refresh freshest possible values
.immediateWithBackgroundRefresh returns cache returns cache and refreshes in background waits for refresh responsive reads with silent catch-up

.immediate

Return a usable cached snapshot immediately when one exists.

  • fresh cache: returned immediately
  • stale but still usable cache: returned immediately
  • no usable cache: refresh is attempted and its result is returned
  • refresh failure with no usable cache: throws

Use this when UI responsiveness matters more than eagerly refreshing stale data.

.refreshBeforeReturning

Prefer a refresh before returning stale data.

  • fresh cache: returned immediately
  • stale cache: refresh is attempted first
  • refresh failure with still-usable stale cache: stale data is returned
  • refresh failure with no usable cache: throws

Use this when you want the newest possible config before continuing, but still need an offline fallback.

.immediateWithBackgroundRefresh

Return usable cached data now and refresh asynchronously in the background.

  • fresh or stale-but-usable cache: returned immediately
  • a background refresh is scheduled
  • no usable cache: refresh is awaited directly

Use this when you want instant reads while still nudging the cache toward freshness.

Freshness Model

The store uses two time windows:

  • ttl: how long a fetched snapshot is considered fresh
  • maxStaleAge: an optional extra window during which expired data may still be served

That creates three states:

  1. fresh The cache is within ttl and can be returned without qualification.

  2. stale but usable The cache is older than ttl, but still within maxStaleAge.

  3. expired and unusable The cache is older than both windows and must not be used as a fallback.

Errors

RemoteConfigStore keeps its store-specific error surface intentionally small.

  • RemoteConfigStoreError.noCachedSnapshot: returned when neither memory nor disk cache contains a snapshot
  • RemoteConfigDecodingError.missingValue(key:): returned when a structured decode asks for a key that is not present
  • RemoteConfigDecodingError.invalidJSONValue(key:): returned when a stored value cannot be converted to JSON data for decoding
  • RemoteConfigDecodingError.decodingFailed(key:description:): returned when JSONDecoder cannot decode the stored value into the requested type

Other failures are propagated from the underlying component that failed:

  • refresh() and snapshot(using:) can throw the injected fetcher's error
  • refresh() can throw cache persistence or file-system errors from disk writes
  • cachedSnapshot() can throw file-system errors while loading from disk
  • RemoteConfigStore.init(...) can throw when the cache directory cannot be prepared

Example App

The repository includes an Xcode example app in Example/RemoteConfigStore.

Current scenarios:

  • Feature Flags Code: FeatureFlagsDemoView.swift Shows typed keys, all three read policies, manual refreshes, revision drift, and the difference between typed accessors and the raw cached payload.
  • HTTP Fetcher Code: HTTPFetcherDemoView.swift Shows the built-in URLSession fetcher, URL-based store construction, typed-key reads, and refresh-result reporting.
  • HTTP Cache Validation Code: HTTPFetcherDemoView.swift Shows persisted ETag metadata, conditional requests, 304 Not Modified, and cache freshness renewal without a payload change.
  • Observability Code: ObservabilityDemoView.swift Shows the async update stream, lightweight update hook, and inspection state API.
  • Structured Payloads Code: StructuredPayloadDemoView.swift Shows nested values, consumer-defined Decodable models, raw snapshot inspection, and decode failure handling.

Planned scenarios:

  • Offline Fallback - Example soon

Documentation

The package now includes a DocC catalog in Sources/RemoteConfigStore/RemoteConfigStore.docc.

Once Swift Package Index processes the .spi.yml manifest and the DocC catalog, hosted documentation should appear on the package page automatically.

Current DocC coverage includes:

  • getting started with injected fetchers
  • read policy behavior
  • built-in HTTP cache validation behavior
  • update observation and inspection state
  • structured value decoding

Example Scenarios

The example app is designed as one showcase application with multiple focused scenarios rather than many separate demo projects.

Implemented:

Coming later:

  • Offline Fallback - Example soon

If you create a fresh example app manually in the future, recommended Xcode options are:

  • template: iOS App
  • interface: SwiftUI
  • language: Swift
  • testing: Swift Testing
  • Core Data: No
  • development team: unset unless signing is needed locally

Testing

Package tests now use Swift Testing, not XCTest.

Current structure:

  • Tests/RemoteConfigStoreTests/Models
  • Tests/RemoteConfigStoreTests/Cache
  • Tests/RemoteConfigStoreTests/Store
  • Tests/RemoteConfigStoreTests/Support

Run the package tests with:

swift test

See CHANGELOG.md for released changes.

Description

  • Swift Tools 6.0.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sun May 17 2026 18:14:12 GMT-0900 (Hawaii-Aleutian Daylight Time)