performFullSync only ever cached each collection's root-level documents — a document's own sub-documents were never cached at all, so nested content stayed unreachable offline even with a full sync (and even after implicitly re-fetching individual documents, since the walk that seeds the cache never visited them). Now recurses depth-first into every document's children via cacheDocumentTree, terminating naturally once a branch runs out of sub-documents. Also caches each collection individually under "collection:<id>" (was only ever cached as part of a paginated list blob), and adds OfflineCacheStore.loadAll(keyPrefix:) plus cachedDocumentsIndex()/cachedCollectionsIndex() on CachingOutlineAPIClient to read everything back as a flat local index — no per-id lookup needed, no network involved. Found and fixed a real infinite-recursion bug in the process: an existing test's stub ignored parentDocumentId and kept returning the same root documents at every recursion depth, hanging swift test indefinitely once the real code started recursing into children. Fixed the stub, added a dedicated regression test for the recursive behavior itself. 78/78 tests passing.
767 lines
33 KiB
Swift
767 lines
33 KiB
Swift
import Foundation
|
|
|
|
/// 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
|
|
|
|
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
|
self.live = live
|
|
self.cache = cache
|
|
self.defaults = defaults
|
|
|
|
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)") { 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: - 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 live.createDocument(request)
|
|
await cacheDocument(result)
|
|
return result
|
|
} catch {
|
|
return await queueDocumentCreate(request)
|
|
}
|
|
}
|
|
return await queueDocumentCreate(request)
|
|
}
|
|
|
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
|
if !isManualOfflineModeEnabled {
|
|
do {
|
|
let result = try await live.updateDocument(request)
|
|
await cacheDocument(result)
|
|
return result
|
|
} catch {
|
|
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 live.updateCollection(request)
|
|
await cacheCollection(result)
|
|
return result
|
|
} catch {
|
|
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 { return try await live.createPin(request) } catch { 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 live.deletePin(id: id)
|
|
return
|
|
} catch {
|
|
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 { return try await live.createSubscription(request) } catch { 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 live.deleteSubscription(id: id)
|
|
return
|
|
} catch {
|
|
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 { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
|
|
}
|
|
return await queueStarDocumentCreate(request)
|
|
}
|
|
|
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
|
if !isManualOfflineModeEnabled {
|
|
do { return try await live.starCollection(request) } catch { 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 live.deleteStar(id: id)
|
|
return
|
|
} catch {
|
|
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 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 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()
|
|
}
|
|
|
|
/// 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 { try? decoder.decode(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 { try? decoder.decode(OutlineCollection.self, from: $0) }
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func cachedFetch<T: Codable>(key: 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 = try? decoder.decode(T.self, from: data) {
|
|
return cached
|
|
}
|
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
|
}
|
|
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)"
|
|
}
|
|
|
|
private func cacheDocument(_ document: OutlineDocument) async {
|
|
if let data = try? encoder.encode(document) {
|
|
await cache.save(data, forKey: "document:\(document.id)")
|
|
}
|
|
}
|
|
|
|
private func cacheCollection(_ collection: OutlineCollection) async {
|
|
if let data = try? encoder.encode(collection) {
|
|
await cache.save(data, forKey: "collection:\(collection.id)")
|
|
}
|
|
}
|
|
|
|
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
|
guard let data = try? encoder.encode(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 = try? decoder.decode(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 = try? decoder.decode(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 = try? decoder.decode(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 decoder.decode(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 decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
|
let result = try await live.updateDocument(request)
|
|
await cacheDocument(result)
|
|
case .updateCollection:
|
|
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
|
|
let result = try await live.updateCollection(request)
|
|
await cacheCollection(result)
|
|
case .createPin:
|
|
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
|
|
_ = try await live.createPin(request)
|
|
case .deletePin:
|
|
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
|
try await live.deletePin(id: request.id)
|
|
case .createSubscription:
|
|
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
|
|
_ = try await live.createSubscription(request)
|
|
case .deleteSubscription:
|
|
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
|
try await live.deleteSubscription(id: request.id)
|
|
case .starDocument:
|
|
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
|
|
_ = try await live.starDocument(request)
|
|
case .deleteStar:
|
|
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
|
try await live.deleteStar(id: request.id)
|
|
case .starCollection:
|
|
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
|
|
_ = try await live.starCollection(request)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|