feat(offline): read-through cache for browse endpoints + offline badge
CachingOutlineAPIClient (OutlineKit) wraps LiveOutlineAPIClient at the existing protocol boundary: always tries live, falls back to a SwiftData key/value cache only on failure, and overwrites the cache on every live success. Applies to the read/browse path only (collections, document lists, document content) — search, writes, and sharing pass straight through uncached. Wired in once at SessionStore.makeAPIClient, so no view model needed to change. 5 new tests via a stub OutlineAPIClient (43/43 passing). NetworkMonitor (NWPathMonitor-backed, app target) is unrelated to the cache's own fallback logic — it only drives a new sidebar OfflineBanner so the user knows when they might be looking at stale content.
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Generic key/value row backing `OfflineCacheStore` — one table for every
|
||||||
|
/// cached response shape instead of a `@Model` per Outline type, so adding a
|
||||||
|
/// new cached endpoint never needs a schema migration.
|
||||||
|
@Model
|
||||||
|
public final class CachedPayload {
|
||||||
|
@Attribute(.unique) public var key: String
|
||||||
|
public var payload: Data
|
||||||
|
public var cachedAt: Date
|
||||||
|
|
||||||
|
public init(key: String, payload: Data, cachedAt: Date) {
|
||||||
|
self.key = key
|
||||||
|
self.payload = payload
|
||||||
|
self.cachedAt = cachedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with a
|
||||||
|
/// read-through cache for the handful of endpoints "browse the workspace
|
||||||
|
/// offline" actually needs: the collection tree, document lists, and a
|
||||||
|
/// document's own content. Every other call — search, writes, sharing,
|
||||||
|
/// pins, etc. — passes straight through, since those either don't make
|
||||||
|
/// sense offline or aren't worth the staleness risk.
|
||||||
|
///
|
||||||
|
/// Deliberately doesn't ask "are we online?" up front — that's a separate
|
||||||
|
/// question from "did this specific request fail?", and checking first can
|
||||||
|
/// be wrong the instant connectivity flaps. Instead: always try live, and
|
||||||
|
/// fall back to the cache only on failure. A successful live response
|
||||||
|
/// always overwrites the cache, so it can never get more stale than the
|
||||||
|
/// last time this actually worked.
|
||||||
|
public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||||
|
private let live: OutlineAPIClient
|
||||||
|
private let cache: OfflineCacheStore
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
private let keyEncoder: JSONEncoder
|
||||||
|
|
||||||
|
public init(live: OutlineAPIClient, cache: OfflineCacheStore) {
|
||||||
|
self.live = live
|
||||||
|
self.cache = cache
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
self.encoder = encoder
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
self.decoder = decoder
|
||||||
|
|
||||||
|
let keyEncoder = JSONEncoder()
|
||||||
|
keyEncoder.outputFormatting = .sortedKeys
|
||||||
|
self.keyEncoder = keyEncoder
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cached reads
|
||||||
|
|
||||||
|
public func documentInfo(id: String) async throws -> OutlineDocument {
|
||||||
|
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listDocuments(
|
||||||
|
collectionId: String?,
|
||||||
|
parentDocumentId: String?,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int
|
||||||
|
) async throws -> [OutlineDocument] {
|
||||||
|
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
||||||
|
return try await cachedFetch(key: key) {
|
||||||
|
try await self.live.listDocuments(
|
||||||
|
collectionId: collectionId,
|
||||||
|
parentDocumentId: parentDocumentId,
|
||||||
|
offset: offset,
|
||||||
|
limit: limit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
||||||
|
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||||
|
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
|
||||||
|
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
|
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
||||||
|
try await self.live.listCollections(offset: offset, limit: limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
||||||
|
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pass-through
|
||||||
|
|
||||||
|
public func authInfo() async throws -> OutlineAuthInfo {
|
||||||
|
try await live.authInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||||
|
try await live.searchDocuments(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
|
||||||
|
try await live.searchDocumentTitles(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||||
|
try await live.createDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
|
try await live.updateDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||||
|
try await live.starDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
||||||
|
try await live.templatizeDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
|
||||||
|
try await live.duplicateDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
|
||||||
|
try await live.unpublishDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func archiveDocument(id: String) async throws -> OutlineDocument {
|
||||||
|
try await live.archiveDocument(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func moveDocument(_ request: MoveDocumentRequest) async throws {
|
||||||
|
try await live.moveDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
|
||||||
|
try await live.deleteDocument(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
|
||||||
|
try await live.documentInsights(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
|
||||||
|
try await live.listRevisions(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exportDocument(id: String) async throws -> String {
|
||||||
|
try await live.exportDocument(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
|
||||||
|
try await live.createShare(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||||
|
try await live.shareInfo(documentId: documentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
||||||
|
try await live.updateShare(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
|
||||||
|
try await live.listShares(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func revokeShare(id: String) async throws {
|
||||||
|
try await live.revokeShare(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
|
try await live.createPin(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||||
|
try await live.listPins(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deletePin(id: String) async throws {
|
||||||
|
try await live.deletePin(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||||
|
try await live.createSubscription(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
|
||||||
|
try await live.listSubscriptions(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteSubscription(id: String) async throws {
|
||||||
|
try await live.deleteSubscription(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
|
||||||
|
try await live.listViews(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
|
try await live.addDocumentUser(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
|
||||||
|
try await live.removeDocumentUser(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
|
||||||
|
try await live.documentUsers(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
|
||||||
|
try await live.listUsers(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||||
|
try await live.updateCollection(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteCollection(id: String) async throws {
|
||||||
|
try await live.deleteCollection(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
|
||||||
|
try await live.exportCollection(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||||
|
try await live.starCollection(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
|
||||||
|
try await live.listStars(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteStar(id: String) async throws {
|
||||||
|
try await live.deleteStar(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func currentUser() async throws -> OutlineUser {
|
||||||
|
try await live.currentUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||||
|
do {
|
||||||
|
let result = try await fetch()
|
||||||
|
if let data = try? encoder.encode(result) {
|
||||||
|
await cache.save(data, forKey: key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||||
|
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return "\(prefix):\(json)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it
|
||||||
|
/// last fetched successfully, keyed by request shape, so browsing keeps
|
||||||
|
/// working (stale) once the network stops.
|
||||||
|
@ModelActor
|
||||||
|
public actor OfflineCacheStore {
|
||||||
|
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
|
||||||
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
|
||||||
|
return try ModelContainer(for: CachedPayload.self, configurations: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(_ data: Data, forKey key: String) {
|
||||||
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||||
|
if let existing = try? modelContext.fetch(descriptor).first {
|
||||||
|
existing.payload = data
|
||||||
|
existing.cachedAt = Date()
|
||||||
|
} else {
|
||||||
|
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
|
||||||
|
}
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(forKey key: String) -> Data? {
|
||||||
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||||
|
return try? modelContext.fetch(descriptor).first?.payload
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OutlineKit
|
||||||
|
|
||||||
|
private struct NotStubbed: Error {}
|
||||||
|
|
||||||
|
/// Conforms to the full protocol (so it can stand in for `live`), but only
|
||||||
|
/// the two methods under test in this file have real behavior — everything
|
||||||
|
/// else throws loudly if a test accidentally exercises it.
|
||||||
|
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
|
||||||
|
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
|
||||||
|
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
|
||||||
|
|
||||||
|
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
|
||||||
|
|
||||||
|
func documentInfo(id: String) async throws -> OutlineDocument {
|
||||||
|
guard let handler = documentInfoHandler else { throw NotStubbed() }
|
||||||
|
return try await handler(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||||
|
throw NotStubbed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
|
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
|
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||||
|
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
|
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
|
||||||
|
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
|
||||||
|
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
|
||||||
|
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
|
||||||
|
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
|
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
|
||||||
|
func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() }
|
||||||
|
func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() }
|
||||||
|
func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() }
|
||||||
|
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() }
|
||||||
|
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() }
|
||||||
|
func exportDocument(id: String) async throws -> String { throw NotStubbed() }
|
||||||
|
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
|
||||||
|
func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() }
|
||||||
|
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
|
||||||
|
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
|
||||||
|
func revokeShare(id: String) async throws { throw NotStubbed() }
|
||||||
|
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { throw NotStubbed() }
|
||||||
|
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
|
||||||
|
func deletePin(id: String) async throws { throw NotStubbed() }
|
||||||
|
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
|
||||||
|
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||||
|
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||||
|
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
|
||||||
|
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||||
|
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||||
|
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
|
||||||
|
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() }
|
||||||
|
|
||||||
|
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
|
guard let handler = listCollectionsHandler else { throw NotStubbed() }
|
||||||
|
return try await handler(offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() }
|
||||||
|
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() }
|
||||||
|
func deleteCollection(id: String) async throws { throw NotStubbed() }
|
||||||
|
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() }
|
||||||
|
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() }
|
||||||
|
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
|
||||||
|
func deleteStar(id: String) async throws { throw NotStubbed() }
|
||||||
|
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StubTransportError: Error {}
|
||||||
|
|
||||||
|
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||||
|
private func makeCache() throws -> OfflineCacheStore {
|
||||||
|
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
|
||||||
|
OutlineDocument(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
text: "body",
|
||||||
|
url: "/doc/\(id)",
|
||||||
|
createdAt: Date(timeIntervalSince1970: 0),
|
||||||
|
updatedAt: Date(timeIntervalSince1970: 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
|
||||||
|
OutlineCollection(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
createdAt: Date(timeIntervalSince1970: 0),
|
||||||
|
updatedAt: Date(timeIntervalSince1970: 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument()
|
||||||
|
stub.documentInfoHandler = { _ in document }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
let result = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(result, document)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument()
|
||||||
|
var callCount = 0
|
||||||
|
stub.documentInfoHandler = { _ in
|
||||||
|
callCount += 1
|
||||||
|
if callCount == 1 { return document }
|
||||||
|
throw StubTransportError()
|
||||||
|
}
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
// First call succeeds and populates the cache.
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
// Second call fails live — should transparently return the cached value.
|
||||||
|
let result = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(result, document)
|
||||||
|
XCTAssertEqual(callCount, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
XCTFail("Expected an error")
|
||||||
|
} catch is StubTransportError {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let collections = [makeCollection()]
|
||||||
|
var callCount = 0
|
||||||
|
stub.listCollectionsHandler = { _, _ in
|
||||||
|
callCount += 1
|
||||||
|
if callCount == 1 { return collections }
|
||||||
|
throw StubTransportError()
|
||||||
|
}
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
|
let result = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
|
|
||||||
|
XCTAssertEqual(result, collections)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDifferentDocumentIdsAreCachedSeparately() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let docOne = makeDocument(id: "doc-1", title: "One")
|
||||||
|
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||||
|
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
_ = try await sut.documentInfo(id: "doc-2")
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
|
||||||
|
let cachedOne = try await sut.documentInfo(id: "doc-1")
|
||||||
|
let cachedTwo = try await sut.documentInfo(id: "doc-2")
|
||||||
|
|
||||||
|
XCTAssertEqual(cachedOne, docOne)
|
||||||
|
XCTAssertEqual(cachedTwo, docTwo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ struct ContentView_macOS: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SidebarSearchField(text: $globalSearchQuery)
|
SidebarSearchField(text: $globalSearchQuery)
|
||||||
Divider()
|
Divider()
|
||||||
|
if !session.networkMonitor.isOnline {
|
||||||
|
OfflineBanner()
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
sidebar
|
sidebar
|
||||||
AccountFooter()
|
AccountFooter()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Shown at the top of the sidebar whenever `NetworkMonitor` reports no
|
||||||
|
/// connection — cached collections/documents keep browsing working, but the
|
||||||
|
/// user should know they might be looking at stale data.
|
||||||
|
struct OfflineBanner: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Offline — showing cached content")
|
||||||
|
.font(.callout)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.orange.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -17,6 +17,11 @@ final class SessionStore {
|
|||||||
var teamName: String?
|
var teamName: String?
|
||||||
var teamAvatarURL: URL?
|
var teamAvatarURL: URL?
|
||||||
private(set) var apiClient: OutlineAPIClient?
|
private(set) var apiClient: OutlineAPIClient?
|
||||||
|
let networkMonitor = NetworkMonitor()
|
||||||
|
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
|
||||||
|
/// full) — in that case `apiClient` falls back to talking to the server
|
||||||
|
/// directly with no offline cache, rather than failing sign-in outright.
|
||||||
|
private let cacheStore: OfflineCacheStore?
|
||||||
|
|
||||||
var serverURL: URL? {
|
var serverURL: URL? {
|
||||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||||
@@ -26,23 +31,27 @@ final class SessionStore {
|
|||||||
self.tokenStore = tokenStore
|
self.tokenStore = tokenStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
self.isSignedIn = (try? tokenStore.token()) != nil
|
||||||
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||||
|
|
||||||
if isSignedIn, let serverURL {
|
if isSignedIn, let serverURL {
|
||||||
apiClient = LiveOutlineAPIClient(
|
apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
||||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
|
||||||
tokenStore: tokenStore
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
||||||
apiClient = LiveOutlineAPIClient(
|
apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
||||||
|
apply(user: user, team: team, serverURL: serverURL)
|
||||||
|
isSignedIn = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeAPIClient(serverURL: URL, tokenStore: TokenStoring, cache: OfflineCacheStore?) -> OutlineAPIClient {
|
||||||
|
let live = LiveOutlineAPIClient(
|
||||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||||
tokenStore: tokenStore
|
tokenStore: tokenStore
|
||||||
)
|
)
|
||||||
apply(user: user, team: team, serverURL: serverURL)
|
guard let cache else { return live }
|
||||||
isSignedIn = true
|
return CachingOutlineAPIClient(live: live, cache: cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() {
|
func signOut() {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Backs the sidebar's offline badge — purely a UI signal. Unrelated to
|
||||||
|
/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual
|
||||||
|
/// request failures rather than pre-checking reachability.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class NetworkMonitor {
|
||||||
|
private(set) var isOnline = true
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "com.outpost.network-monitor")
|
||||||
|
|
||||||
|
init() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
let online = path.status == .satisfied
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.isOnline = online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user