Files
Outpost/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift
T
Puranjay Savar Mattas 8b4c5852ed feat(offline): support creating documents offline
createDocument now queues and syncs like the other offline-capable
writes, closing the gap deliberately left open earlier this session.
Synthesizes a pending-<uuid> document (same placeholder scheme as
pin/star/subscribe), caches it so it's immediately readable/openable,
and queues the real create. On sync, the server's real document
replaces the placeholder in the cache — no dead orphan entries.

Editing that same still-unsynced document folds the edit into the
pending create's payload instead of queuing a separate update, which
would otherwise target the placeholder id and 404 forever once
flushed. CreateDocumentRequest widened Encodable -> Codable for the
queue round-trip.

Known limitation: a reader still open on that exact document at the
moment it syncs in the background keeps holding the stale placeholder
id until navigated away and back. Narrow, not solved generally here.

3 new OutlineKit tests (57/57). Removed the now-unneeded offline
restriction on the reader's New Document button/menu item.
2026-08-15 01:46:32 +01:00

666 lines
28 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()
}
// 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).
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 {
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
} catch {
errors.append("\(collection.name): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
}
documentsCount += documents.count
guard documents.count == limit else { break }
offset += limit
}
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
// 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)
}
}