Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
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
159 changes: 159 additions & 0 deletions Sources/Cache/Cache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// 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
let 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()

// 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() {
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 {
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,
ttl: dehydratedTree.ttl,
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,
ttl: response.ttl,
cachedAt: Date(),
lastAccessed: Date(),
rootObject: rootObj
)
)

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)
}
30 changes: 30 additions & 0 deletions Sources/Cache/CacheSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// 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.

/// Specifies the cache configuration for a Firebase Data Connect instance
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public struct CacheSettings: Sendable {
public enum Storage: Sendable {
case persistent
case memory
}

public let storage: Storage // default provider is persistent type
public let maxSizeBytes: UInt64

public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Maybe generate a doc comment for this method.

self.storage = storage
maxSizeBytes = maxSize
}
}
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