Skip to content

Commit f2da2b3

Browse files
author
Hoang Pham
committed
chore: file structure
1 parent 7998e76 commit f2da2b3

14 files changed

Lines changed: 1285 additions & 1269 deletions
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import Foundation
2+
3+
// MARK: - Garbage Collector
4+
5+
/// Centralized garbage collector that runs at configurable intervals
6+
/// to clean up inactive queries across all caches
7+
@MainActor
8+
public final class GarbageCollector {
9+
/// Shared instance for global garbage collection
10+
public static let shared = GarbageCollector()
11+
12+
/// Default garbage collection interval (30 seconds)
13+
public static let defaultInterval: TimeInterval = 30
14+
15+
/// Current garbage collection interval
16+
public private(set) var interval: TimeInterval
17+
18+
/// Timer for periodic garbage collection
19+
private var timer: Timer?
20+
21+
/// Set of query caches to monitor
22+
private var caches: Set<ObjectIdentifier> = []
23+
24+
/// Weak references to query caches
25+
private var cacheReferences: [ObjectIdentifier: WeakQueryCacheRef] = [:]
26+
27+
/// Whether garbage collection is currently running
28+
private var isRunning = false
29+
30+
private init(interval: TimeInterval = defaultInterval) {
31+
self.interval = interval
32+
}
33+
34+
/// Configure garbage collection interval
35+
/// - Parameter interval: Time interval between GC runs (in seconds)
36+
public func configure(interval: TimeInterval) {
37+
self.interval = interval
38+
39+
// Restart timer with new interval if currently running
40+
if isRunning {
41+
stop()
42+
start()
43+
}
44+
}
45+
46+
/// Start periodic garbage collection
47+
public func start() {
48+
guard !isRunning else { return }
49+
50+
isRunning = true
51+
52+
#if DEBUG
53+
print("🗑️ SwiftUI Query: Starting GarbageCollector with \(interval)s interval")
54+
#endif
55+
56+
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
57+
Task { @MainActor [weak self] in
58+
self?.collectGarbage()
59+
}
60+
}
61+
}
62+
63+
/// Stop periodic garbage collection
64+
public func stop() {
65+
guard isRunning else { return }
66+
67+
isRunning = false
68+
timer?.invalidate()
69+
timer = nil
70+
71+
#if DEBUG
72+
print("🗑️ SwiftUI Query: Stopping GarbageCollector")
73+
#endif
74+
}
75+
76+
/// Register a query cache for garbage collection monitoring
77+
/// - Parameter cache: Query cache to monitor
78+
public func register(_ cache: QueryCache) {
79+
let id = ObjectIdentifier(cache)
80+
caches.insert(id)
81+
cacheReferences[id] = WeakQueryCacheRef(cache: cache)
82+
83+
// Start GC if this is the first cache and we're not running
84+
if caches.count == 1, !isRunning {
85+
start()
86+
}
87+
}
88+
89+
/// Unregister a query cache from garbage collection monitoring
90+
/// - Parameter cache: Query cache to stop monitoring
91+
public func unregister(_ cache: QueryCache) {
92+
let id = ObjectIdentifier(cache)
93+
caches.remove(id)
94+
cacheReferences.removeValue(forKey: id)
95+
96+
// Stop GC if no caches remain
97+
if caches.isEmpty {
98+
stop()
99+
}
100+
}
101+
102+
/// Manually trigger garbage collection across all registered caches
103+
public func collectGarbage() {
104+
// Clean up deallocated cache references first
105+
cleanupDeadReferences()
106+
107+
// Early return if no caches to process
108+
guard !cacheReferences.isEmpty else { return }
109+
110+
let startTime = Date()
111+
var totalQueries = 0
112+
var removedQueries = 0
113+
114+
// Collect garbage from all live caches
115+
for (id, cacheRef) in cacheReferences {
116+
guard let cache = cacheRef.cache else {
117+
// Cache was deallocated, remove reference
118+
caches.remove(id)
119+
cacheReferences.removeValue(forKey: id)
120+
continue
121+
}
122+
123+
let queries = cache.allQueries
124+
totalQueries += queries.count
125+
126+
// Find inactive queries eligible for removal
127+
let inactiveQueries = queries.filter { query in
128+
isEligibleForRemoval(query, cache: cache)
129+
}
130+
131+
// Remove inactive queries
132+
for query in inactiveQueries {
133+
cache.remove(query)
134+
removedQueries += 1
135+
136+
#if DEBUG
137+
print("🗑️ SwiftUI Query: GC removed inactive query \(query.queryHash)")
138+
#endif
139+
}
140+
}
141+
142+
let duration = Date().timeIntervalSince(startTime)
143+
144+
#if DEBUG
145+
if removedQueries > 0 {
146+
print(
147+
"🗑️ SwiftUI Query: GC completed - removed \(removedQueries)/\(totalQueries) queries in \(String(format: "%.2f", duration * 1000))ms"
148+
)
149+
}
150+
#endif
151+
}
152+
153+
/// Check if a query is eligible for garbage collection
154+
/// - Parameters:
155+
/// - query: Query to check
156+
/// - cache: Cache containing the query
157+
/// - Returns: true if query should be removed
158+
private func isEligibleForRemoval(_ query: AnyQuery, cache: QueryCache) -> Bool {
159+
// Use the query's own GC eligibility logic
160+
query.isEligibleForGC
161+
}
162+
163+
/// Clean up references to deallocated caches
164+
private func cleanupDeadReferences() {
165+
let deadReferences = cacheReferences.compactMap { id, ref -> ObjectIdentifier? in
166+
ref.cache == nil ? id : nil
167+
}
168+
169+
for id in deadReferences {
170+
caches.remove(id)
171+
cacheReferences.removeValue(forKey: id)
172+
}
173+
}
174+
}
175+
176+
/// Weak reference wrapper for query caches
177+
private class WeakQueryCacheRef {
178+
weak var cache: QueryCache?
179+
180+
init(cache: QueryCache) {
181+
self.cache = cache
182+
}
183+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import Foundation
2+
3+
// MARK: - Infinite Query Types
4+
5+
/// Function signature for infinite query functions
6+
public typealias InfiniteQueryFunction<TData: Sendable, TKey: QueryKey, TPageParam: Sendable & Codable> =
7+
@Sendable (
8+
TKey,
9+
TPageParam?
10+
) async throws -> TData
11+
12+
/// Function to get the next page parameter
13+
public typealias GetNextPageParamFunction<TData: Sendable, TPageParam: Sendable & Codable> =
14+
@Sendable ([TData]) -> TPageParam?
15+
16+
/// Function to get the previous page parameter
17+
public typealias GetPreviousPageParamFunction<TData: Sendable, TPageParam: Sendable & Codable> =
18+
@Sendable ([TData]) -> TPageParam?
19+
20+
/// Configuration options for infinite queries
21+
/// Equivalent to TanStack Query's InfiniteQueryOptions
22+
public struct InfiniteQueryOptions<
23+
TData: Sendable,
24+
TError: Error & Sendable & Codable,
25+
TKey: QueryKey,
26+
TPageParam: Sendable & Codable & Equatable
27+
>: Sendable, Equatable {
28+
/// The query key that uniquely identifies this query
29+
public let queryKey: TKey
30+
/// The function that will be called to fetch data pages
31+
public let queryFn: InfiniteQueryFunction<TData, TKey, TPageParam>
32+
/// Function to determine the next page parameter
33+
public let getNextPageParam: GetNextPageParamFunction<TData, TPageParam>?
34+
/// Function to determine the previous page parameter
35+
public let getPreviousPageParam: GetPreviousPageParamFunction<TData, TPageParam>?
36+
/// Initial page parameter for the first page
37+
public let initialPageParam: TPageParam?
38+
/// Maximum number of pages to retain
39+
public let maxPages: Int?
40+
/// Configuration for retry behavior
41+
public let retryConfig: RetryConfig
42+
/// Network behavior configuration
43+
public let networkMode: NetworkMode
44+
/// Time after which data is considered stale (in seconds)
45+
public let staleTime: TimeInterval
46+
/// Time after which inactive queries are garbage collected (in seconds)
47+
public let gcTime: TimeInterval
48+
/// Configuration for automatic refetching triggers
49+
public let refetchTriggers: RefetchTriggers
50+
/// Specific behavior for view appear events
51+
public let refetchOnAppear: RefetchOnAppear
52+
/// Whether to use structural sharing for performance
53+
public let structuralSharing: Bool
54+
/// Arbitrary metadata for this query
55+
public let meta: QueryMeta?
56+
/// Whether this query is enabled (will fetch automatically)
57+
public let enabled: Bool
58+
59+
public static func == (
60+
lhs: InfiniteQueryOptions<TData, TError, TKey, TPageParam>,
61+
rhs: InfiniteQueryOptions<TData, TError, TKey, TPageParam>
62+
) -> Bool {
63+
lhs.queryKey == rhs.queryKey &&
64+
lhs.initialPageParam == rhs.initialPageParam &&
65+
lhs.maxPages == rhs.maxPages &&
66+
lhs.staleTime == rhs.staleTime &&
67+
lhs.gcTime == rhs.gcTime &&
68+
lhs.refetchTriggers == rhs.refetchTriggers &&
69+
lhs.refetchOnAppear == rhs.refetchOnAppear &&
70+
lhs.enabled == rhs.enabled
71+
}
72+
73+
public init(
74+
queryKey: TKey,
75+
queryFn: @escaping InfiniteQueryFunction<TData, TKey, TPageParam>,
76+
getNextPageParam: GetNextPageParamFunction<TData, TPageParam>? = nil,
77+
getPreviousPageParam: GetPreviousPageParamFunction<TData, TPageParam>? = nil,
78+
initialPageParam: TPageParam? = nil,
79+
maxPages: Int? = nil,
80+
retryConfig: RetryConfig = RetryConfig(),
81+
networkMode: NetworkMode = .online,
82+
staleTime: TimeInterval = 0,
83+
gcTime: TimeInterval = defaultGcTime,
84+
refetchTriggers: RefetchTriggers = .default,
85+
refetchOnAppear: RefetchOnAppear = .always,
86+
structuralSharing: Bool = true,
87+
meta: QueryMeta? = nil,
88+
enabled: Bool = true
89+
) {
90+
self.queryKey = queryKey
91+
self.queryFn = queryFn
92+
self.getNextPageParam = getNextPageParam
93+
self.getPreviousPageParam = getPreviousPageParam
94+
self.initialPageParam = initialPageParam
95+
self.maxPages = maxPages
96+
self.retryConfig = retryConfig
97+
self.networkMode = networkMode
98+
self.staleTime = staleTime
99+
self.gcTime = gcTime
100+
self.refetchTriggers = refetchTriggers
101+
self.refetchOnAppear = refetchOnAppear
102+
self.structuralSharing = structuralSharing
103+
self.meta = meta
104+
self.enabled = enabled
105+
}
106+
}
107+
108+
/// Container for infinite query data with pagination support
109+
/// Equivalent to TanStack Query's InfiniteData
110+
public struct InfiniteData<TData: Sendable, TPageParam: Sendable & Codable>: Sendable {
111+
/// Array of pages containing the actual data
112+
public let pages: [TData]
113+
/// Array of page parameters used to fetch each page
114+
public let pageParams: [TPageParam?]
115+
116+
public init(pages: [TData] = [], pageParams: [TPageParam?] = []) {
117+
self.pages = pages
118+
self.pageParams = pageParams
119+
}
120+
121+
/// Add a new page to the end
122+
public func appendPage(_ page: TData, param: TPageParam?) -> InfiniteData<TData, TPageParam> {
123+
var newPages = pages
124+
var newParams = pageParams
125+
newPages.append(page)
126+
newParams.append(param)
127+
return Self(pages: newPages, pageParams: newParams)
128+
}
129+
130+
/// Add a new page to the beginning
131+
public func prependPage(_ page: TData, param: TPageParam?) -> InfiniteData<TData, TPageParam> {
132+
var newPages = pages
133+
var newParams = pageParams
134+
newPages.insert(page, at: 0)
135+
newParams.insert(param, at: 0)
136+
return Self(pages: newPages, pageParams: newParams)
137+
}
138+
139+
/// Remove pages beyond the specified maximum
140+
public func limitPages(to maxPages: Int) -> InfiniteData<TData, TPageParam> {
141+
guard maxPages > 0, pages.count > maxPages else { return self }
142+
143+
let limitedPages = Array(pages.prefix(maxPages))
144+
let limitedParams = Array(pageParams.prefix(maxPages))
145+
return Self(pages: limitedPages, pageParams: limitedParams)
146+
}
147+
148+
/// Get the total number of pages
149+
public var pageCount: Int {
150+
pages.count
151+
}
152+
153+
/// Check if there are any pages
154+
public var isEmpty: Bool {
155+
pages.isEmpty
156+
}
157+
158+
/// Get the last page parameter (for next page fetching)
159+
public var lastPageParam: TPageParam? {
160+
pageParams.last.flatMap(\.self)
161+
}
162+
163+
/// Get the first page parameter (for previous page fetching)
164+
public var firstPageParam: TPageParam? {
165+
pageParams.first.flatMap(\.self)
166+
}
167+
168+
/// Flatten all pages into a single array if TData is a collection
169+
public func flatMap<Element>() -> [Element] where TData == [Element] {
170+
pages.flatMap(\.self)
171+
}
172+
}

0 commit comments

Comments
 (0)