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:
2026-08-14 23:41:00 +01:00
parent b28dc019aa
commit eee00197bb
8 changed files with 554 additions and 7 deletions
@@ -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
+16 -7
View File
@@ -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() {
+29
View File
@@ -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()
}
}