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.
This commit is contained in:
@@ -104,6 +104,27 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
// MARK: - Queueable writes
|
// 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 {
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
@@ -214,10 +235,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.searchDocumentTitles(request)
|
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 {
|
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
||||||
try await live.templatizeDocument(request)
|
try await live.templatizeDocument(request)
|
||||||
}
|
}
|
||||||
@@ -467,6 +484,30 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
return await cache.removeOperation(id: id)
|
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 {
|
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
||||||
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
||||||
@@ -490,6 +531,26 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
deletedAt: base.deletedAt
|
deletedAt: base.deletedAt
|
||||||
)
|
)
|
||||||
await cacheDocument(merged)
|
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
|
// Fully resolved (not the original partial request) so replaying just
|
||||||
// this one queued operation reproduces `merged` exactly — that's what
|
// this one queued operation reproduces `merged` exactly — that's what
|
||||||
// makes coalescing a second edit onto the same queued id safe.
|
// makes coalescing a second edit onto the same queued id safe.
|
||||||
@@ -549,6 +610,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
switch 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:
|
case .updateDocument:
|
||||||
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
||||||
let result = try await live.updateDocument(request)
|
let result = try await live.updateDocument(request)
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ public actor OfflineCacheStore {
|
|||||||
return try? modelContext.fetch(descriptor).first?.payload
|
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<CachedPayload>(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 {
|
public func itemCount() -> Int {
|
||||||
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public struct FullSyncSummary: Sendable {
|
|||||||
/// replay later. Deliberately a small, explicit set — see its doc comment
|
/// replay later. Deliberately a small, explicit set — see its doc comment
|
||||||
/// for what's excluded and why.
|
/// for what's excluded and why.
|
||||||
enum PendingOperationKind: String, Codable, Sendable {
|
enum PendingOperationKind: String, Codable, Sendable {
|
||||||
|
case createDocument
|
||||||
case updateDocument
|
case updateDocument
|
||||||
case updateCollection
|
case updateCollection
|
||||||
case createPin
|
case createPin
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct CreateDocumentRequest: Encodable, Sendable {
|
public struct CreateDocumentRequest: Codable, Sendable {
|
||||||
public let title: String
|
public let title: String
|
||||||
public let text: String
|
public let text: String
|
||||||
public let collectionId: String
|
public let collectionId: String
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
|
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
|
||||||
var deletePinHandler: (@Sendable (String) async throws -> Void)?
|
var deletePinHandler: (@Sendable (String) async throws -> Void)?
|
||||||
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
|
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
|
||||||
|
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
|
||||||
|
|
||||||
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
|
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 listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { 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 {
|
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
guard let handler = updateDocumentHandler else { throw NotStubbed() }
|
guard let handler = updateDocumentHandler else { throw NotStubbed() }
|
||||||
@@ -418,4 +422,79 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let cachedDoc = try await sut.documentInfo(id: "doc-2")
|
let cachedDoc = try await sut.documentInfo(id: "doc-2")
|
||||||
XCTAssertEqual(cachedDoc.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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ struct DocumentReaderView: View {
|
|||||||
self.onDocumentCreated = onDocumentCreated
|
self.onDocumentCreated = onDocumentCreated
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Editing, pin/star/subscribe, and Full Width all queue and sync later
|
/// New Document, editing, pin/star/subscribe, and Full Width all queue
|
||||||
/// (see `CachingOutlineAPIClient`) — everything else here (sharing,
|
/// and sync later (see `CachingOutlineAPIClient`) — everything else here
|
||||||
/// permissions, move/archive/delete/duplicate/templatize, history,
|
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
|
||||||
/// insights, export) hits the server directly with no offline path, so
|
/// history, insights, export) hits the server directly with no offline
|
||||||
/// it's disabled rather than left to fail confusingly on tap.
|
/// path, so it's disabled rather than left to fail confusingly on tap.
|
||||||
private var isEffectivelyOnline: Bool {
|
private var isEffectivelyOnline: Bool {
|
||||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
}
|
}
|
||||||
@@ -143,8 +143,7 @@ struct DocumentReaderView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "doc.badge.plus")
|
Image(systemName: "doc.badge.plus")
|
||||||
}
|
}
|
||||||
.help(isEffectivelyOnline ? "New Document" : "Creating documents needs an internet connection")
|
.help("New Document")
|
||||||
.disabled(!isEffectivelyOnline)
|
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
menuContent
|
menuContent
|
||||||
@@ -353,7 +352,6 @@ struct DocumentReaderView: View {
|
|||||||
Button("New Document") {
|
Button("New Document") {
|
||||||
isShowingNewDocumentSheet = true
|
isShowingNewDocumentSheet = true
|
||||||
}
|
}
|
||||||
.disabled(!isEffectivelyOnline)
|
|
||||||
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
|
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
|
||||||
Task { await togglePin() }
|
Task { await togglePin() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user