From 8b4c5852ed52a2ac5ce10a1650473ca2bdb2d33c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 01:46:32 +0100 Subject: [PATCH] feat(offline): support creating documents offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createDocument now queues and syncs like the other offline-capable writes, closing the gap deliberately left open earlier this session. Synthesizes a pending- 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. --- .../Caching/CachingOutlineAPIClient.swift | 76 ++++++++++++++++- .../Caching/OfflineCacheStore.swift | 10 +++ .../OutlineKit/Caching/SyncTypes.swift | 1 + .../Requests/CreateDocumentRequest.swift | 2 +- .../CachingOutlineAPIClientTests.swift | 81 ++++++++++++++++++- .../Collections/DocumentReaderView.swift | 14 ++-- 6 files changed, 170 insertions(+), 14 deletions(-) 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() } }