A lightweight Swift 6 library for managing asynchronous data sources with reactive streams.
- ๐ Reactive Streams - AsyncStream-based value and state emissions
- ๐ฆ Smart Caching - Automatic caching with TTL support
- โก๏ธ Time Controls - Throttle, debounce, and auto-refresh
- ๐ Retry Logic - Configurable retry strategies with exponential backoff
- ๐ฏ Prerequisites - Conditional fetching based on runtime checks
- ๐ Dependencies - React to changes in other data sources
- ๐ญ Distinct Values - Filter duplicate emissions
- ๐งต Swift 6 - Full concurrency support with Sendable conformance
Add Silo to your Swift package dependencies:
dependencies: [
.package(url: "https://github.com/kliliom/Silo.git", from: "2.0.0")
]import Silo
@MainActor
class UserService {
let userSource: DataSource<User?>
init() {
userSource = dataSource {
try await API.getUser()
} onError: { error in
print("Error:", error)
return .keep // Keep cached data on error
}
.ttl(.seconds(300)) // Cache for 5 minutes
.distinct() // Only emit when user changes
.build()
}
var user: AsyncStream<User?> {
userSource.values
}
func refresh() async throws {
try await userSource.refresh()
}
}- Initial State: DataSource starts with the empty value (
nilfor Optional,[]for Array, etc.) - First Fetch: Call
refresh()to fetch data - Caching: Successful fetch result is cached and emitted to all streams
- Updates: Streams receive updates when data changes
let source = dataSource {
try await API.getData()
} onError: { error in
return .keep // or .clear
}
.build()
// Stream values
Task {
for await value in source.values {
print("New value:", value)
}
}
// Manually refresh
try await source.refresh()Monitor loading and validity state:
Task {
for await state in source.state {
print("Refreshing:", state.isRefreshing)
print("Empty:", state.isEmpty)
}
}Cache data for a specific duration:
dataSource {
try await API.getConfig()
} onError: { _ in
.keep
}
.ttl(.seconds(300)) // Cache for 5 minutes
.build()Auto-clear after expiry:
.ttl(.seconds(900), clear: true) // Clear data after 15 minutesLimit refresh frequency:
.throttle(.seconds(2)) // Max one fetch per 2 secondsExecute last call in window:
.throttle(.seconds(2), last: true)Wait for calls to settle:
.debounce(.milliseconds(300)) // Wait 300ms after last callAutomatic periodic refreshing:
.autoRefresh(.seconds(30)) // Refresh every 30 secondsControl at runtime:
source.stopAutoRefresh()
source.resumeAutoRefresh()
await source.restartAutoRefresh(immediate: true).retry(maxAttempts: 3) // Up to 3 attempts in total: 1 initial + up to 2 retries.retry(maxAttempts: 3, delay: .seconds(2)) // Wait 2 seconds between retries.retry(strategy: .exponentialBackoff(
maxAttempts: 5,
initialDelay: .seconds(1),
multiplier: 2.0,
maxDelay: .seconds(30)
)).retry(maxAttempts: 3, delay: .seconds(1)) { error in
if case APIError.unauthorized = error {
return .stop // Stop retrying โ defer cache decision to top-level onError
}
return .retry // Continue retrying
}Only emit when value actually changes:
.distinct() // For Equatable typesCustom comparison:
.distinct { old, new in
old?.id == new?.id // Compare by ID only
}Check conditions before fetching:
struct NetworkPrerequisite: DataSourceRefreshPrerequisite {
func check() async -> Bool {
// Check network availability
return await NetworkMonitor.isConnected
}
}
dataSource {
try await API.getData()
} onError: { _ in .keep }
.requires(NetworkPrerequisite())
.build()React to changes in other data sources:
let userSource = dataSource { ... }.build()
let postsSource = dataSource(
userSource.values.dependency(.eager, clear: true)
) { user in
guard let user = user else { return [] }
return try await API.getPosts(userId: user.id)
} onError: { _ in .keep }
.build()let resultsSource = dataSource(
querySource.values.dependency(.eager, clear: true),
filtersSource.values.dependency(.eager, clear: true)
) { query, filters in
try await API.search(query: query, filters: filters)
} onError: { _ in .keep }
.debounce(.milliseconds(300))
.build().eager- Refresh immediately when dependency changes, even without subscribers.lazy- Refresh only when there are subscribers. If a dependency changes while there are no subscribers, the refresh is deferred until the first subscriber arrives.manual- Don't auto-refresh on changes (manual refresh only)
Two error enums, each used in a different place:
FetchErrorAction โ returned from the top-level onError to control cache contents after all retries are exhausted:
onError: { error in
if error is NetworkError {
return .keep // Preserve cached data
} else {
return .clear // Reset to empty value
}
}RetryErrorAction โ returned from the per-attempt onError in .retry() to control whether the next retry runs:
.retry(maxAttempts: 3) { error in
return .retry // Try again, or .stop to defer cache decision to top-level onError
}Automatic empty values for common types:
// Optional - defaults to nil
dataSource { ... as String? } onError: { _ in .keep }
// Array - defaults to []
dataSource { ... as [Item] } onError: { _ in .keep }
// Dictionary - defaults to [:]
dataSource { ... as [Key: Value] } onError: { _ in .keep }Custom types require emptyValue:
dataSource {
try await API.getData()
} onError: { _ in .keep } emptyValue: {
Data.empty // Custom empty value
}@MainActor
@Observable
class ViewModel {
let service = UserService()
var user: User?
var isLoading = false
func observe() {
Task {
for await user in service.user {
self.user = user
}
}
Task {
for await state in service.userSource.state {
isLoading = state.isRefreshing
}
}
}
func refresh() async {
try? await service.refresh()
}
}
struct ContentView: View {
@State var viewModel = ViewModel()
var body: some View {
Text(viewModel.user?.name ?? "Loading...")
.task {
viewModel.observe()
try? await viewModel.refresh()
}
.refreshable {
await viewModel.refresh()
}
}
}Combining multiple features:
let feedSource = dataSource(
userSource.values.dependency(.eager, clear: true)
) { user in
guard let user = user else { return [] }
return try await API.getFeed(userId: user.id)
} onError: { error in
print("Feed error:", error)
return .keep
}
.ttl(.seconds(300)) // Cache for 5 minutes
.throttle(.seconds(2)) // Limit refresh rate
.debounce(.milliseconds(300)) // Debounce rapid calls
.autoRefresh(.seconds(30)) // Auto-refresh every 30s
.distinct() // Filter duplicates
.retry(strategy: .exponentialBackoff(
maxAttempts: 3,
initialDelay: .seconds(1),
multiplier: 2.0
))
.requires(NetworkPrerequisite()) // Only fetch when online
.build()- Swift 6.0+
- iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+
Silo uses Swift Testing:
swift testPortions of this project โ including documentation, and tests โ were developed with the assistance of Claude Code.
This project is licensed under the MIT License - see the LICENSE.txt file for details.