Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7c92921
Initial Stub Classes
aashishpatil-g Aug 19, 2025
f56a8cc
Wire up Initialization of CacheProvider
aashishpatil-g Aug 20, 2025
f655a15
query level caching
aashishpatil-g Aug 22, 2025
90c7ae1
Cache Normalization Implementation
aashishpatil-g Aug 28, 2025
5174e03
Persistent Provider
aashishpatil-g Aug 29, 2025
ad71e3c
Add last_accessed field
aashishpatil-g Sep 4, 2025
c0a024b
Uber Cache object, support Auth uid scope and Refactor classes
aashishpatil-g Sep 24, 2025
0bcdf09
Implement operationId on QueryRef
aashishpatil-g Sep 25, 2025
ad749e7
Fix sha256 formatter.
aashishpatil-g Sep 25, 2025
35dc280
Accumulate Impacted Refs and Reload Local
aashishpatil-g Oct 1, 2025
6cd60ef
Update construction of cacheIdentifier to include connector and locat…
aashishpatil-g Oct 1, 2025
824a41e
Use inmemory SQLite for Ephemeral cache provider
aashishpatil-g Oct 2, 2025
f503f5e
Minor updates to API
aashishpatil-g Oct 7, 2025
e57bbb4
Refactor BackingDataObject, STubDataObject names to EntityDataObject …
aashishpatil-g Oct 8, 2025
353caf5
DispatchQueue for EntityDataObject access
aashishpatil-g Oct 9, 2025
78599a3
Externalize table and column name strings into constants
aashishpatil-g Oct 10, 2025
250e905
API Review feedback
aashishpatil-g Oct 10, 2025
f1aa717
Code formatting updates
aashishpatil-g Oct 11, 2025
d9b4807
Refactor name of OperationResultSource
aashishpatil-g Oct 13, 2025
1c666ec
API feedback - rename maxSize => maxSizeBytes
aashishpatil-g Oct 13, 2025
8ea6762
API Council Review Feedback
aashishpatil-g Oct 15, 2025
a807d9f
Move ServerResponse
aashishpatil-g Oct 24, 2025
336e41b
Update cache type to match API review feedback
aashishpatil-g Nov 5, 2025
37493c4
Cleanup and minor fixes to docs
aashishpatil-g Nov 11, 2025
dd8569c
Update globalID value
aashishpatil-g Nov 11, 2025
fad7e2f
Fix format
aashishpatil-g Nov 12, 2025
a62c16b
Fix copyright notices
aashishpatil-g Nov 12, 2025
30940a6
Fix @available
aashishpatil-g Nov 12, 2025
5482987
fix integration tests
aashishpatil-g Nov 12, 2025
1a9aeb9
Gemini Review part 1
aashishpatil-g Nov 13, 2025
12da757
formatting fixes
aashishpatil-g Nov 13, 2025
334e710
Convert sync queue calls to actor
aashishpatil-g Nov 13, 2025
b1569e8
comments
aashishpatil-g Nov 13, 2025
517fdcd
Nick feedback
aashishpatil-g Nov 18, 2025
b9265dc
Remove unnecessary custom codable in EDO
aashishpatil-g Nov 19, 2025
6689c9c
Convert data connect to a weak reference in cache
aashishpatil-g Nov 19, 2025
2b90b52
formatting fixes
aashishpatil-g Nov 19, 2025
edfcd83
Doc comments for Settings
aashishpatil-g Nov 19, 2025
2572377
Incorporate maxAge
aashishpatil-g Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions Sources/BaseOperationRef.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@

import Foundation

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public struct OperationResult<ResultData: Decodable & Sendable>: Sendable {
public var data: ResultData
}

// notional protocol that denotes a variable.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public protocol OperationVariable: Encodable, Hashable, Equatable, Sendable {}
Expand All @@ -31,7 +26,7 @@ protocol OperationRequest: Hashable, Equatable, Sendable {
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public protocol OperationRef {
public protocol OperationRef: Hashable, Equatable {
associatedtype ResultData: Decodable & Sendable

func execute() async throws -> OperationResult<ResultData>
Expand Down
174 changes: 174 additions & 0 deletions Sources/Cache/Cache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import FirebaseAuth

// FDC field name in server response that identifies a GlobalID
let GlobalIDKey: String = "_id"

// Client cache that internally uses a CacheProvider to store content.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
actor Cache {
let config: CacheSettings
weak var dataConnect: DataConnect?

private var cacheProvider: CacheProvider?

// holding it to avoid dealloc
private var authChangeListenerProtocol: NSObjectProtocol?

init(config: CacheSettings, dataConnect: DataConnect) {
self.config = config
self.dataConnect = dataConnect

// this is a potential race since update or get could get scheduled before initialize
// workarounds are complex since caller DataConnect APIs aren't async
Task {
await initializeCacheProvider()
await setupChangeListeners()
}
}

private func initializeCacheProvider() {
let identifier = contructCacheIdentifier()

guard identifier.isEmpty == false else {
DataConnectLogger.error("CacheIdentifier is empty. Caching is disabled")
return
}

// Create a cacheProvider if -
// we don't have an existing cacheProvider
// we have one but its identifier is different than new one (e.g. auth uid changed)
if cacheProvider != nil, cacheProvider?.cacheIdentifier == identifier {
return
}

do {
switch config.storage {
case .memory:
cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true)
case .persistent:
cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: false)
}
} catch {
DataConnectLogger.error("Unable to initialize Persistent provider \(error)")
}
}

private func setupChangeListeners() {
guard let dataConnect else {
DataConnectLogger.error("Unable to setup auth change listeners since DataConnect is nil")
return
}

authChangeListenerProtocol = Auth.auth(app: dataConnect.app).addStateDidChangeListener { _, _ in
self.initializeCacheProvider()
}
}

// Create an identifier for the cache that the Provider will use for cache scoping
private func contructCacheIdentifier() -> String {
guard let dataConnect else {
DataConnectLogger.error("Unable to construct a cache identifier since DataConnect is nil")
return ""
}

let identifier =
"\(config.storage)-\(dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)"
let encoded = identifier.sha256
DataConnectLogger.debug("Created Encoded Cache Identifier \(encoded) for \(identifier)")
return encoded
}

func resultTree(queryId: String) -> ResultTree? {
// result trees are stored dehydrated in the cache
// retrieve cache, hydrate it and then return
guard let cacheProvider else {
DataConnectLogger.error("CacheProvider is nil in the Cache")
return nil
}

guard let dehydratedTree = cacheProvider.resultTree(queryId: queryId) else {
return nil
}

do {
let resultsProcessor = ResultTreeProcessor()
let (hydratedResults, rootObj) = try resultsProcessor.hydrateResults(
dehydratedTree.data,
cacheProvider: cacheProvider
)

let hydratedTree = ResultTree(
data: hydratedResults,
cachedAt: dehydratedTree.cachedAt,
lastAccessed: dehydratedTree.lastAccessed,
rootObject: rootObj
)

return hydratedTree
} catch {
DataConnectLogger.warning("Error decoding result tree \(error)")
return nil
}
}

func update(queryId: String, response: ServerResponse, requestor: (any QueryRefInternal)? = nil) {
// server response contains hydrated trees
// dehydrate (normalize) the results and store dehydrated trees
guard let cacheProvider = cacheProvider else {
DataConnectLogger
.debug("Cache provider not initialized yet. Skipping update for \(queryId)")
return
}
do {
let processor = ResultTreeProcessor()
let (dehydratedResults, rootObj, impactedRefs) = try processor.dehydrateResults(
response.jsonResults,
cacheProvider: cacheProvider,
requestor: requestor
)

cacheProvider
.setResultTree(
queryId: queryId,
tree: .init(
data: dehydratedResults,
cachedAt: Date(),
lastAccessed: Date(),
rootObject: rootObj
)
)

if let dataConnect {
for refId in impactedRefs {
guard let q = dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else {
continue
}
Task {
do {
try await q.publishCacheResultsToSubscribers(allowStale: true)
} catch {
DataConnectLogger
.warning("Error republishing cached results for impacted queryrefs \(error))")
}
}
}
}
} catch {
DataConnectLogger.warning("Error updating cache for \(queryId): \(error)")
}
}
}
31 changes: 31 additions & 0 deletions Sources/Cache/CacheProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation

import FirebaseCore

// Key to store cache provider in Codables userInfo object.
let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")!

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
protocol CacheProvider {
var cacheIdentifier: String { get }

func resultTree(queryId: String) -> ResultTree?
func setResultTree(queryId: String, tree: ResultTree)

func entityData(_ entityGuid: String) -> EntityDataObject
func updateEntityData(_ object: EntityDataObject)
}
56 changes: 56 additions & 0 deletions Sources/Cache/CacheSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation

/// Specifies the cache configuration for a `DataConnect` instance.
///
/// You can configure the cache's storage policy and its maximum size.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public struct CacheSettings: Sendable {
/// Defines the storage mechanism for the cache.
public enum Storage: Sendable {
/// The cache will be written to disk, persisting data across application launches.
case persistent
/// The cache will only be stored in memory and will be cleared when the application terminates.
case memory
}

/// The storage mechanism to be used for caching. The default is `.persistent`.
public let storage: Storage
/// The maximum size of the cache in bytes.
///
/// This size is not strictly enforced but is used as a guideline by the cache
/// to trigger cleanup procedures. The default is 100MB (100,000,000 bytes).
public let maxSizeBytes: UInt64

/// Max time interval before a queries cache is considered stale and refreshed from the server
/// This interval does not imply that cached data is evicted and it can still be accessed using
/// the `cacheOnly` fetch policy
public let maxAge: TimeInterval

/// Creates a new cache settings configuration.
///
/// - Parameters:
/// - storage: The storage mechanism to use. Defaults to `.persistent`.
/// - maxSize: The maximum desired size of the cache in bytes. Defaults to 100MB.
/// - maxAge: The max time interval before a queries cache is considered stale and refreshed
/// from the server
public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000,
maxAge: TimeInterval = 0) {
self.storage = storage
maxSizeBytes = maxSize
self.maxAge = maxAge
}
}
22 changes: 22 additions & 0 deletions Sources/Cache/DynamicCodingKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Used for inline inline hydration of entity values
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct DynamicCodingKey: CodingKey {
var intValue: Int?
let stringValue: String
init?(intValue: Int) { return nil }
init?(stringValue: String) { self.stringValue = stringValue }
}
Loading
Loading