diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index b5f465a..fe30556 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -104,6 +104,27 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // MARK: - Queueable writes + /// The one exception to "no new tree structure while offline" — + /// synthesizes a document under a `pending-` 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 { @@ -214,10 +235,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.searchDocumentTitles(request) } - public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { - try await live.createDocument(request) - } - public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { try await live.templatizeDocument(request) } @@ -467,6 +484,30 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { 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 { @@ -490,6 +531,26 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { 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. @@ -549,6 +610,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { )) } 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) diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift index 3fd1e8e..1300a18 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -31,6 +31,16 @@ public actor OfflineCacheStore { return try? modelContext.fetch(descriptor).first?.payload } + /// Used to drop a temporary `pending-*` document's cache entry once a + /// queued create syncs and the server hands back the real id — the + /// placeholder key would otherwise sit around as a dead orphan forever. + public func removeCacheEntry(forKey key: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + guard let existing = try? modelContext.fetch(descriptor).first else { return } + modelContext.delete(existing) + try? modelContext.save() + } + public func itemCount() -> Int { (try? modelContext.fetchCount(FetchDescriptor())) ?? 0 } diff --git a/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift index 863221b..601e412 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift @@ -32,6 +32,7 @@ public struct FullSyncSummary: Sendable { /// replay later. Deliberately a small, explicit set — see its doc comment /// for what's excluded and why. enum PendingOperationKind: String, Codable, Sendable { + case createDocument case updateDocument case updateCollection case createPin diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift index 07de5ea..7aafdfc 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct CreateDocumentRequest: Encodable, Sendable { +public struct CreateDocumentRequest: Codable, Sendable { public let title: String public let text: String public let collectionId: String diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 45c9841..ffeb429 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -13,6 +13,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? var deletePinHandler: (@Sendable (String) async throws -> Void)? var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])? + var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)? func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } @@ -30,7 +31,10 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() } func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } - func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + guard let handler = createDocumentHandler else { throw NotStubbed() } + return try await handler(request) + } func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { guard let handler = updateDocumentHandler else { throw NotStubbed() } @@ -418,4 +422,79 @@ final class CachingOutlineAPIClientTests: XCTestCase { let cachedDoc = try await sut.documentInfo(id: "doc-2") XCTAssertEqual(cachedDoc.id, "doc-2") } + + // MARK: - Offline document creation + + func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws { + let stub = StubOutlineAPIClient() + stub.createDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let created = try await sut.createDocument( + CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") + ) + + XCTAssertTrue(created.id.hasPrefix("pending-")) + XCTAssertEqual(created.title, "New Doc") + XCTAssertEqual(created.text, "hello") + + // It's immediately readable, same as any other cached document. + stub.documentInfoHandler = { _ in throw StubTransportError() } + let reopened = try await sut.documentInfo(id: created.id) + XCTAssertEqual(reopened.title, "New Doc") + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + XCTAssertEqual(pending.first?.kind, "createDocument") + } + + func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws { + let stub = StubOutlineAPIClient() + stub.createDocumentHandler = { _ in throw StubTransportError() } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let created = try await sut.createDocument( + CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1") + ) + _ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body")) + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation") + XCTAssertEqual(pending.first?.kind, "createDocument") + + stub.documentInfoHandler = { _ in throw StubTransportError() } + let reopened = try await sut.documentInfo(id: created.id) + XCTAssertEqual(reopened.title, "Real Title") + XCTAssertEqual(reopened.text, "Real body") + } + + func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws { + let stub = StubOutlineAPIClient() + stub.createDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let created = try await sut.createDocument( + CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") + ) + let realDocument = makeDocument(id: "real-server-id", title: "New Doc") + stub.createDocumentHandler = { _ in realDocument } + + let summary = await sut.flushPendingOperations() + XCTAssertEqual(summary.succeeded, 1) + XCTAssertEqual(summary.failed, 0) + + // The real document is now readable under its real id... + stub.documentInfoHandler = { _ in throw StubTransportError() } + let byRealId = try await sut.documentInfo(id: "real-server-id") + XCTAssertEqual(byRealId.id, "real-server-id") + + // ...and the placeholder is gone rather than left as a dead orphan. + do { + _ = try await sut.documentInfo(id: created.id) + XCTFail("Expected the placeholder cache entry to have been removed") + } catch { + // expected — nothing left under the old id + } + } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 6a5e56b..97e9e9c 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -57,11 +57,11 @@ struct DocumentReaderView: View { self.onDocumentCreated = onDocumentCreated } - /// Editing, pin/star/subscribe, and Full Width all queue and sync later - /// (see `CachingOutlineAPIClient`) — everything else here (sharing, - /// permissions, move/archive/delete/duplicate/templatize, history, - /// insights, export) hits the server directly with no offline path, so - /// it's disabled rather than left to fail confusingly on tap. + /// New Document, editing, pin/star/subscribe, and Full Width all queue + /// and sync later (see `CachingOutlineAPIClient`) — everything else here + /// (sharing, permissions, move/archive/delete/duplicate/templatize, + /// history, insights, export) hits the server directly with no offline + /// path, so it's disabled rather than left to fail confusingly on tap. private var isEffectivelyOnline: Bool { session.networkMonitor.isOnline && !isOfflineModeEnabled } @@ -143,8 +143,7 @@ struct DocumentReaderView: View { } label: { Image(systemName: "doc.badge.plus") } - .help(isEffectivelyOnline ? "New Document" : "Creating documents needs an internet connection") - .disabled(!isEffectivelyOnline) + .help("New Document") Menu { menuContent @@ -353,7 +352,6 @@ struct DocumentReaderView: View { Button("New Document") { isShowingNewDocumentSheet = true } - .disabled(!isEffectivelyOnline) Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Task { await togglePin() } }