Both CachedPayload and PendingOperation only ever stored their payload as plain JSON on disk (SwiftData/SQLite, no encryption of its own) - readable by anyone with access to the logged-in session, per the earlier discussion on where this cache lives. Adds AES-GCM encryption at the one place raw bytes cross into/out of OfflineCacheStore (CachingOutlineAPIClient, which already owns encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob store, since its key/id/kind columns can't be encrypted without breaking the #Predicate queries built against them. Key management (KeychainCacheEncryptionKeyStore, mirrors KeychainTokenStore exactly): a random 256-bit key, generated once and Keychain-stored, not derived from anything guessable. It doesn't need deriving to survive an uninstall/reinstall either - Keychain items are scoped to the app's code signature, not its on-disk presence, so a reinstall of the same app regains access to the same key automatically (same reason a saved API token already survives a reinstall today). If the on-disk cache also happens to survive (dragging the .app to the Trash doesn't clean ~/Library/Containers), a reinstall can still read it. A row written before this shipped (still plaintext) or encrypted under a since-cleared key just fails to decrypt and is treated as a cache miss - same as any other decode failure, so it silently refetches and re-caches encrypted rather than crashing. No explicit migration needed. Also: OfflineCacheStore.clearEverything() wipes both the cache AND the pending write queue (clearAll(), used by Settings' "Clear All Cache", still only touches the cache - it shouldn't silently discard someone's unsynced edits). Exposed as CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to use alongside clearing the key. CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests never touch the real Keychain - existing CachingOutlineAPIClientTests now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests (retry/failure-log tests from the previous commit plus 3 new ones here: ciphertext isn't plaintext JSON, a cleared key makes old rows unreadable, clearEverythingForSignOut wipes both tables). 95/95 passing.
961 lines
42 KiB
Swift
961 lines
42 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
|
|
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
|
/// support at the existing protocol boundary, so no view model needs to know
|
|
/// the network exists:
|
|
///
|
|
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
|
|
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
|
|
/// cache. Always tries live first — never pre-checks reachability, since
|
|
/// "did this specific request just fail" is a more honest signal than a
|
|
/// reachability monitor, and the two can disagree (captive portals,
|
|
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
|
|
/// always overwrites the cache, so staleness is bounded by "last time this
|
|
/// endpoint actually worked."
|
|
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
|
|
/// pin/star/subscribe create+delete): applied optimistically against the
|
|
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
|
|
/// failure, then replayed in order by `flushPendingOperations()` once back
|
|
/// online. Deliberately doesn't include anything that creates new tree
|
|
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
|
|
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) — those need
|
|
/// real server-assigned ids to stay consistent with the rest of the tree,
|
|
/// and reconciling a locally-invented id with the one the server hands
|
|
/// back on sync is a much bigger problem than this pass takes on. Sharing,
|
|
/// permissions, search, and export also stay live-only — read the request,
|
|
/// they need someone else's server session, not just a network.
|
|
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
|
|
/// attempting `live` entirely — same fallback/queue paths as a real
|
|
/// failure, just chosen on purpose instead of discovered.
|
|
public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
|
|
|
|
private let live: OutlineAPIClient
|
|
private let cache: OfflineCacheStore
|
|
private let defaults: UserDefaults
|
|
private let encoder: JSONEncoder
|
|
private let decoder: JSONDecoder
|
|
private let keyEncoder: JSONEncoder
|
|
private let encryptionKeyStore: CacheEncryptionKeyStoring
|
|
/// Resolved lazily and kept for this instance's lifetime — a fresh
|
|
/// instance is created on every sign-in/sign-out anyway (see
|
|
/// `SessionStore.makeAPIClient`), so there's no staleness risk, just
|
|
/// one fewer Keychain round-trip per cache read/write.
|
|
private var cachedEncryptionKey: SymmetricKey?
|
|
|
|
/// Recent failure timestamps per category — see `recordFailure` /
|
|
/// `repeatedFailureSummaries()`. A category only shows up there once it's
|
|
/// failed `failureThreshold` times within `failureWindow`; a single
|
|
/// transient blip (which `RetryPolicy` already tries to absorb) never
|
|
/// reaches this at all.
|
|
private var failureLog: [String: [Date]] = [:]
|
|
private var lastFailureMessage: [String: String] = [:]
|
|
private let failureWindow: TimeInterval = 300
|
|
private let failureThreshold: Int = 3
|
|
|
|
public init(
|
|
live: OutlineAPIClient,
|
|
cache: OfflineCacheStore,
|
|
defaults: UserDefaults = .standard,
|
|
encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore()
|
|
) {
|
|
self.live = live
|
|
self.cache = cache
|
|
self.defaults = defaults
|
|
self.encryptionKeyStore = encryptionKeyStore
|
|
|
|
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
|
|
}
|
|
|
|
private var isManualOfflineModeEnabled: Bool {
|
|
defaults.bool(forKey: Self.offlineModeDefaultsKey)
|
|
}
|
|
|
|
// MARK: - Cached reads
|
|
|
|
public func documentInfo(id: String) async throws -> OutlineDocument {
|
|
try await cachedFetch(key: "document:\(id)", category: "document") { 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, category: "documents") {
|
|
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), category: "documents") { try await self.live.documentsList(request) }
|
|
}
|
|
|
|
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
|
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
|
|
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
|
}
|
|
}
|
|
|
|
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
|
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)", category: "drafts") {
|
|
try await self.live.listDrafts(request)
|
|
}
|
|
}
|
|
|
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
|
try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
|
|
try await self.live.listCollections(offset: offset, limit: limit)
|
|
}
|
|
}
|
|
|
|
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
|
try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) }
|
|
}
|
|
|
|
// MARK: - Queueable writes
|
|
|
|
/// The one exception to "no new tree structure while offline" —
|
|
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
|
|
/// pin/star/subscribe), caches it so it's immediately readable/editable,
|
|
/// and queues the real create. On sync, the server-assigned id replaces
|
|
/// the placeholder in the cache; anything still referencing the old id
|
|
/// directly (a currently-open reader, most likely) won't follow that
|
|
/// rename automatically — a known, narrow limitation, not something this
|
|
/// pass tries to solve generally.
|
|
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) }
|
|
recordSuccess(category: "documents-write")
|
|
await cacheDocument(result)
|
|
return result
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
|
return await queueDocumentCreate(request)
|
|
}
|
|
}
|
|
return await queueDocumentCreate(request)
|
|
}
|
|
|
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
|
|
recordSuccess(category: "documents-write")
|
|
await cacheDocument(result)
|
|
return result
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
|
return try await queueDocumentUpdate(request, dueTo: error)
|
|
}
|
|
}
|
|
return try await queueDocumentUpdate(request, dueTo: nil)
|
|
}
|
|
|
|
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
|
|
recordSuccess(category: "collections-write")
|
|
await cacheCollection(result)
|
|
return result
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "collections-write", error)
|
|
return try await queueCollectionUpdate(request, dueTo: error)
|
|
}
|
|
}
|
|
return try await queueCollectionUpdate(request, dueTo: nil)
|
|
}
|
|
|
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) }
|
|
recordSuccess(category: "pins")
|
|
return result
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "pins", error)
|
|
return await queuePinCreate(request)
|
|
}
|
|
}
|
|
return await queuePinCreate(request)
|
|
}
|
|
|
|
public func deletePin(id: String) async throws {
|
|
if await cancelIfNeverSynced(id: id) { return }
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
|
|
recordSuccess(category: "pins")
|
|
return
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "pins", error)
|
|
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
|
return
|
|
}
|
|
}
|
|
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
|
}
|
|
|
|
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) }
|
|
recordSuccess(category: "subscriptions")
|
|
return result
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
|
return await queueSubscriptionCreate(request)
|
|
}
|
|
}
|
|
return await queueSubscriptionCreate(request)
|
|
}
|
|
|
|
public func deleteSubscription(id: String) async throws {
|
|
if await cancelIfNeverSynced(id: id) { return }
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
|
|
recordSuccess(category: "subscriptions")
|
|
return
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
|
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
|
return
|
|
}
|
|
}
|
|
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
|
}
|
|
|
|
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) }
|
|
recordSuccess(category: "stars")
|
|
return result
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "stars", error)
|
|
return await queueStarDocumentCreate(request)
|
|
}
|
|
}
|
|
return await queueStarDocumentCreate(request)
|
|
}
|
|
|
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) }
|
|
recordSuccess(category: "stars")
|
|
return result
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "stars", error)
|
|
return await queueStarCollectionCreate(request)
|
|
}
|
|
}
|
|
return await queueStarCollectionCreate(request)
|
|
}
|
|
|
|
public func deleteStar(id: String) async throws {
|
|
if await cancelIfNeverSynced(id: id) { return }
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
|
|
recordSuccess(category: "stars")
|
|
return
|
|
} catch {
|
|
recordWriteFailureIfStructural(category: "stars", error)
|
|
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
|
return
|
|
}
|
|
}
|
|
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(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 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 listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
|
try await live.listPins(request)
|
|
}
|
|
|
|
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
|
|
try await live.listSubscriptions(request)
|
|
}
|
|
|
|
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
|
|
try await live.listViews(request)
|
|
}
|
|
|
|
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
|
try await live.listComments(request)
|
|
}
|
|
|
|
public func commentInfo(id: String) async throws -> OutlineComment {
|
|
try await live.commentInfo(id: id)
|
|
}
|
|
|
|
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
|
try await live.createComment(request)
|
|
}
|
|
|
|
public func resolveComment(id: String) async throws -> OutlineComment {
|
|
try await live.resolveComment(id: id)
|
|
}
|
|
|
|
public func unresolveComment(id: String) async throws -> OutlineComment {
|
|
try await live.unresolveComment(id: id)
|
|
}
|
|
|
|
public func addReaction(commentId: String, emoji: String) async throws {
|
|
try await live.addReaction(commentId: commentId, emoji: emoji)
|
|
}
|
|
|
|
public func removeReaction(commentId: String, emoji: String) async throws {
|
|
try await live.removeReaction(commentId: commentId, emoji: emoji)
|
|
}
|
|
|
|
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 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 listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
|
|
try await live.listStars(request)
|
|
}
|
|
|
|
public func currentUser() async throws -> OutlineUser {
|
|
try await live.currentUser()
|
|
}
|
|
|
|
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
|
try await live.createAttachment(request)
|
|
}
|
|
|
|
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
|
try await live.uploadAttachmentFile(result, fileData: fileData)
|
|
}
|
|
|
|
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
|
try await live.fetchAuthenticatedFile(path: path)
|
|
}
|
|
|
|
public func deleteAttachment(id: String) async throws {
|
|
try await live.deleteAttachment(id: id)
|
|
}
|
|
|
|
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
|
try await live.updateUserAvatar(request)
|
|
}
|
|
|
|
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
|
try await live.updateUserName(request)
|
|
}
|
|
|
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
|
try await live.updateUserLanguage(request)
|
|
}
|
|
|
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
|
try await live.updateUserPreferences(request)
|
|
}
|
|
|
|
public func deleteAccount() async throws {
|
|
try await live.deleteAccount()
|
|
}
|
|
|
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
|
try await live.subscribeToNotifications(eventType: eventType)
|
|
}
|
|
|
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
|
try await live.unsubscribeFromNotifications(eventType: eventType)
|
|
}
|
|
|
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
|
try await live.listApiKeys(request)
|
|
}
|
|
|
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
|
try await live.createApiKey(request)
|
|
}
|
|
|
|
public func deleteApiKey(id: String) async throws {
|
|
try await live.deleteApiKey(id: id)
|
|
}
|
|
|
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
|
try await live.installationInfo()
|
|
}
|
|
|
|
// MARK: - Sync management (Settings surface)
|
|
|
|
public func pendingOperations() async -> [PendingOperationSummary] {
|
|
await cache.pendingOperations().map {
|
|
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
|
|
}
|
|
}
|
|
|
|
public func cacheStorageSummary() async -> CacheStorageSummary {
|
|
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
|
|
}
|
|
|
|
public func clearCache() async {
|
|
await cache.clearAll()
|
|
}
|
|
|
|
/// Sign-out only — see `OfflineCacheStore.clearEverything()` and
|
|
/// `CacheEncryptionKeyStoring.clear()`. Callers must clear the
|
|
/// encryption key too (this actor doesn't own that decision); wiping
|
|
/// the storage here without it would leave the key to be reused by
|
|
/// whoever signs in next.
|
|
public func clearEverythingForSignOut() async {
|
|
await cache.clearEverything()
|
|
}
|
|
|
|
/// Replays every queued operation against `live`, in the order they were
|
|
/// queued. Each is independent — one failing doesn't block the rest.
|
|
public func flushPendingOperations() async -> SyncFlushSummary {
|
|
let operations = await cache.pendingOperations()
|
|
var succeeded = 0
|
|
var failed = 0
|
|
for operation in operations {
|
|
do {
|
|
try await replay(operation)
|
|
await cache.removeOperation(id: operation.id)
|
|
succeeded += 1
|
|
} catch {
|
|
await cache.recordFailure(id: operation.id, error: errorDescription(error))
|
|
failed += 1
|
|
}
|
|
}
|
|
return SyncFlushSummary(succeeded: succeeded, failed: failed)
|
|
}
|
|
|
|
/// "Full Local Sync": eagerly walks every collection and caches every
|
|
/// document's full content (not just whatever's been opened), so
|
|
/// browsing offline works for the whole workspace, not only what was
|
|
/// already viewed. Goes through `self`, not `live`, directly — the
|
|
/// existing cached-read methods already do the caching as a side effect,
|
|
/// this just has to drive the walk and separately cache each document by
|
|
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
|
///
|
|
/// Recurses into every document's children, not just collections' own
|
|
/// root-level documents — a document with sub-documents used to leave
|
|
/// them uncached entirely (only reachable if something else happened to
|
|
/// open them individually first). Also caches each collection under its
|
|
/// own `"collection:<id>"` key (previously only cached as part of the
|
|
/// paginated list blob), so both are individually enumerable afterward
|
|
/// via `OfflineCacheStore.loadAll(keyPrefix:)` — see
|
|
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
|
|
public func performFullSync() async -> FullSyncSummary {
|
|
var documentsCount = 0
|
|
var errors: [String] = []
|
|
var collections: [OutlineCollection] = []
|
|
var collectionsOffset = 0
|
|
let collectionsLimit = 100
|
|
// Outline rejects any `limit` over 100 outright — page in increments
|
|
// of that instead of guessing a total up front (the protocol doesn't
|
|
// expose `pagination`'s total count, only the page itself); a page
|
|
// shorter than the limit is what signals "that was the last one".
|
|
while true {
|
|
let page: [OutlineCollection]
|
|
do {
|
|
page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit)
|
|
} catch {
|
|
errors.append(errorDescription(error))
|
|
break
|
|
}
|
|
collections.append(contentsOf: page)
|
|
guard page.count == collectionsLimit else { break }
|
|
collectionsOffset += collectionsLimit
|
|
}
|
|
|
|
for collection in collections {
|
|
await cacheCollection(collection)
|
|
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
|
|
documentsCount += result.count
|
|
errors.append(contentsOf: result.errors)
|
|
}
|
|
|
|
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
|
}
|
|
|
|
/// Caches every document under `parentDocumentId` (`nil` = a
|
|
/// collection's root level) and recurses into each one's own children,
|
|
/// depth-first, until a branch runs out of sub-documents. Returns a
|
|
/// plain `(count, errors)` pair rather than mutating shared state across
|
|
/// `await` boundaries, since this calls itself recursively.
|
|
private func cacheDocumentTree(
|
|
collectionId: String,
|
|
parentDocumentId: String?,
|
|
collectionName: String
|
|
) async -> (count: Int, errors: [String]) {
|
|
var count = 0
|
|
var errors: [String] = []
|
|
var offset = 0
|
|
let limit = 100
|
|
while true {
|
|
let documents: [OutlineDocument]
|
|
do {
|
|
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
|
|
} catch {
|
|
errors.append("\(collectionName): \(errorDescription(error))")
|
|
break
|
|
}
|
|
for document in documents {
|
|
await cacheDocument(document)
|
|
count += 1
|
|
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
|
|
count += childResult.count
|
|
errors.append(contentsOf: childResult.errors)
|
|
}
|
|
guard documents.count == limit else { break }
|
|
offset += limit
|
|
}
|
|
return (count, errors)
|
|
}
|
|
|
|
/// Every individually cached document from the last Full Local Sync —
|
|
/// empty if a sync has never run (or found nothing). Purely a local
|
|
/// SwiftData read, no network involved.
|
|
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
|
let payloads = await cache.loadAll(keyPrefix: "document:")
|
|
return payloads.compactMap { decryptedDecode(OutlineDocument.self, from: $0) }
|
|
}
|
|
|
|
/// Every individually cached collection from the last Full Local Sync.
|
|
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
|
let payloads = await cache.loadAll(keyPrefix: "collection:")
|
|
return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) }
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
|
|
// Manual offline mode means "skip the network entirely," not just
|
|
// "prefer it" — without this check, a read would still hit `live`
|
|
// (and succeed, showing content beyond whatever's cached) any time
|
|
// the device actually had a connection, defeating the point of
|
|
// deliberately testing/working as if offline.
|
|
if isManualOfflineModeEnabled {
|
|
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
|
|
return cached
|
|
}
|
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
|
}
|
|
do {
|
|
let result = try await RetryPolicy.withRetry { try await fetch() }
|
|
recordSuccess(category: category)
|
|
if let data = encryptedEncode(result) {
|
|
await cache.save(data, forKey: key)
|
|
}
|
|
return result
|
|
} catch {
|
|
// Only a structural failure (decode/auth/server — the server
|
|
// answered, but something's actually wrong) counts toward the
|
|
// repeated-failure log. Plain connectivity loss already has its
|
|
// own offline UI elsewhere; logging it here too would just be a
|
|
// second banner for the same thing every time Wi-Fi drops.
|
|
if !RetryPolicy.isRetryable(error) {
|
|
recordFailure(category: category, message: errorDescription(error))
|
|
}
|
|
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
|
|
return cached
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func resolvedEncryptionKey() throws -> SymmetricKey {
|
|
if let cachedEncryptionKey { return cachedEncryptionKey }
|
|
let key = try encryptionKeyStore.key()
|
|
cachedEncryptionKey = key
|
|
return key
|
|
}
|
|
|
|
/// Encrypts before it ever reaches SwiftData. `nil` on any failure
|
|
/// (matches the shape of the plain `try? encoder.encode(...)` this
|
|
/// replaces) — a Keychain hiccup here should behave exactly like an
|
|
/// encode failure already did: skip caching this one value, not crash.
|
|
private func encryptedEncode(_ value: some Encodable) -> Data? {
|
|
guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil }
|
|
return try? CachePayloadCryptor.encrypt(plain, key: key)
|
|
}
|
|
|
|
/// Decrypts + decodes a value previously written by `encryptedEncode`.
|
|
/// A row written before this feature shipped (still plaintext JSON, or
|
|
/// anything encrypted under a key that's since been cleared by
|
|
/// sign-out) fails to decrypt and returns `nil` here — same as any
|
|
/// other decode failure, so `cachedFetch` treats it as a cache miss and
|
|
/// refetches, not a crash.
|
|
private func decryptedDecode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
|
|
guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil }
|
|
return try? decoder.decode(type, from: plain)
|
|
}
|
|
|
|
/// Throwing counterpart for `replay(_:)`, where a genuine decode
|
|
/// failure needs to propagate (so `flushPendingOperations` records it
|
|
/// as a failed sync attempt) instead of silently vanishing.
|
|
private func decryptedDecodeThrowing<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
|
let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey())
|
|
return try decoder.decode(type, from: plain)
|
|
}
|
|
|
|
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)"
|
|
}
|
|
|
|
private func cacheDocument(_ document: OutlineDocument) async {
|
|
if let data = encryptedEncode(document) {
|
|
await cache.save(data, forKey: "document:\(document.id)")
|
|
}
|
|
}
|
|
|
|
private func cacheCollection(_ collection: OutlineCollection) async {
|
|
if let data = encryptedEncode(collection) {
|
|
await cache.save(data, forKey: "collection:\(collection.id)")
|
|
}
|
|
}
|
|
|
|
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
|
guard let data = encryptedEncode(payload) else { return }
|
|
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
|
|
}
|
|
|
|
/// A delete targeting a `pending-*` id can only mean "cancel the create
|
|
/// still sitting in the queue" — the server has never heard of that id,
|
|
/// so queuing the delete would just fail once synced. Returns whether it
|
|
/// found (and removed) a matching create, meaning the caller is done.
|
|
private func cancelIfNeverSynced(id: String) async -> Bool {
|
|
guard id.hasPrefix("pending-") else { return false }
|
|
return await cache.removeOperation(id: id)
|
|
}
|
|
|
|
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
|
|
let pendingId = "pending-\(UUID().uuidString)"
|
|
let now = Date()
|
|
let synthesized = OutlineDocument(
|
|
id: pendingId,
|
|
title: request.title,
|
|
text: request.text,
|
|
emoji: nil,
|
|
collectionId: request.collectionId,
|
|
parentDocumentId: request.parentDocumentId,
|
|
url: "/doc/\(pendingId)",
|
|
revision: nil,
|
|
fullWidth: nil,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
publishedAt: request.publish ? now : nil,
|
|
archivedAt: nil,
|
|
deletedAt: nil
|
|
)
|
|
await cacheDocument(synthesized)
|
|
await enqueue(.createDocument, payload: request, id: pendingId)
|
|
return synthesized
|
|
}
|
|
|
|
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
|
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
|
let base = decryptedDecode(OutlineDocument.self, from: baseData) else {
|
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
|
}
|
|
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
|
let merged = OutlineDocument(
|
|
id: base.id,
|
|
title: request.title ?? base.title,
|
|
text: mergedText,
|
|
emoji: base.emoji,
|
|
collectionId: base.collectionId,
|
|
parentDocumentId: base.parentDocumentId,
|
|
url: base.url,
|
|
revision: base.revision,
|
|
fullWidth: request.fullWidth ?? base.fullWidth,
|
|
createdAt: base.createdAt,
|
|
updatedAt: Date(),
|
|
publishedAt: base.publishedAt,
|
|
archivedAt: base.archivedAt,
|
|
deletedAt: base.deletedAt
|
|
)
|
|
await cacheDocument(merged)
|
|
|
|
// Still-unsynced create for this exact document — fold the edit into
|
|
// the pending create's payload instead of queuing a separate update.
|
|
// A separate update would target `merged.id`, which is still the
|
|
// `pending-*` placeholder at replay time; the server has never heard
|
|
// of it and the update would just fail every retry.
|
|
if merged.id.hasPrefix("pending-"),
|
|
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
|
let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) {
|
|
let resolvedCreate = CreateDocumentRequest(
|
|
title: merged.title,
|
|
text: merged.text,
|
|
collectionId: createRequest.collectionId,
|
|
parentDocumentId: createRequest.parentDocumentId,
|
|
publish: createRequest.publish
|
|
)
|
|
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
|
|
return merged
|
|
}
|
|
|
|
// Fully resolved (not the original partial request) so replaying just
|
|
// this one queued operation reproduces `merged` exactly — that's what
|
|
// makes coalescing a second edit onto the same queued id safe.
|
|
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
|
|
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
|
|
return merged
|
|
}
|
|
|
|
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
|
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
|
let base = decryptedDecode(OutlineCollection.self, from: baseData) else {
|
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
|
}
|
|
let merged = OutlineCollection(
|
|
id: base.id,
|
|
name: request.name ?? base.name,
|
|
description: request.description ?? base.description,
|
|
color: base.color,
|
|
icon: base.icon,
|
|
createdAt: base.createdAt,
|
|
updatedAt: Date()
|
|
)
|
|
await cacheCollection(merged)
|
|
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
|
|
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
|
|
return merged
|
|
}
|
|
|
|
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
|
|
let pendingId = "pending-\(UUID().uuidString)"
|
|
await enqueue(.createPin, payload: request, id: pendingId)
|
|
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
|
|
}
|
|
|
|
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
|
|
let pendingId = "pending-\(UUID().uuidString)"
|
|
await enqueue(.createSubscription, payload: request, id: pendingId)
|
|
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
|
|
}
|
|
|
|
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
|
|
let pendingId = "pending-\(UUID().uuidString)"
|
|
await enqueue(.starDocument, payload: request, id: pendingId)
|
|
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
|
|
}
|
|
|
|
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
|
|
let pendingId = "pending-\(UUID().uuidString)"
|
|
await enqueue(.starCollection, payload: request, id: pendingId)
|
|
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
|
|
}
|
|
|
|
private func replay(_ operation: PendingOperation) async throws {
|
|
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
|
|
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
|
|
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
|
|
))
|
|
}
|
|
switch kind {
|
|
case .createDocument:
|
|
let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
|
|
let result = try await live.createDocument(request)
|
|
await cacheDocument(result)
|
|
// The placeholder id (== operation.id) is now a dead orphan —
|
|
// nothing server-side will ever answer to it again.
|
|
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
|
case .updateDocument:
|
|
let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
|
|
let result = try await live.updateDocument(request)
|
|
await cacheDocument(result)
|
|
case .updateCollection:
|
|
let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
|
|
let result = try await live.updateCollection(request)
|
|
await cacheCollection(result)
|
|
case .createPin:
|
|
let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
|
|
_ = try await live.createPin(request)
|
|
case .deletePin:
|
|
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
|
try await live.deletePin(id: request.id)
|
|
case .createSubscription:
|
|
let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
|
|
_ = try await live.createSubscription(request)
|
|
case .deleteSubscription:
|
|
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
|
try await live.deleteSubscription(id: request.id)
|
|
case .starDocument:
|
|
let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
|
|
_ = try await live.starDocument(request)
|
|
case .deleteStar:
|
|
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
|
try await live.deleteStar(id: request.id)
|
|
case .starCollection:
|
|
let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
|
|
_ = try await live.starCollection(request)
|
|
}
|
|
}
|
|
|
|
/// Writes always queue on any failure (existing behavior, unchanged) —
|
|
/// this only decides whether the failure is worth logging. A structural
|
|
/// error (decode/auth/server) queuing for later replay will likely just
|
|
/// fail the same way again next sync; a transient one might not. Either
|
|
/// way the queue doesn't change, only whether it's counted toward
|
|
/// `repeatedFailureSummaries()`.
|
|
private func recordWriteFailureIfStructural(category: String, _ error: Error) {
|
|
guard !RetryPolicy.isRetryable(error) else { return }
|
|
recordFailure(category: category, message: errorDescription(error))
|
|
}
|
|
|
|
private func recordFailure(category: String, message: String) {
|
|
let now = Date()
|
|
var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow }
|
|
timestamps.append(now)
|
|
failureLog[category] = timestamps
|
|
lastFailureMessage[category] = message
|
|
}
|
|
|
|
private func recordSuccess(category: String) {
|
|
failureLog[category] = nil
|
|
lastFailureMessage[category] = nil
|
|
}
|
|
|
|
/// Categories that have failed `failureThreshold`+ times within the last
|
|
/// `failureWindow` seconds — meant to be polled periodically (see
|
|
/// `RootView`), not pushed, since this actor has no UI-facing dependency
|
|
/// of its own. A single blip never shows up here: `RetryPolicy` absorbs
|
|
/// transient failures before they're ever logged, and only structural
|
|
/// ones (decode/auth/server) get logged at all — see
|
|
/// `recordWriteFailureIfStructural` and `cachedFetch`.
|
|
public func repeatedFailureSummaries() async -> [RepeatedFailure] {
|
|
let now = Date()
|
|
return failureLog.compactMap { category, timestamps in
|
|
let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow }
|
|
guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil }
|
|
return RepeatedFailure(category: category, message: message, count: recent.count)
|
|
}
|
|
}
|
|
|
|
private func errorDescription(_ error: Error) -> String {
|
|
if let apiError = error as? OutlineAPIError {
|
|
switch apiError {
|
|
case .unauthorized: return "Sign-in expired."
|
|
case .notFound: return "Not found on the server."
|
|
case .server(let status, let message): return message ?? "Server error (\(status))."
|
|
case .decoding: return "Unexpected response shape."
|
|
case .transport: return "Couldn't reach the server."
|
|
case .tokenUnavailable: return "Couldn't access the saved sign-in."
|
|
}
|
|
}
|
|
return String(describing: error)
|
|
}
|
|
}
|