diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 1ce7639..dc374c6 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -433,6 +433,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { /// 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:"` 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] = [] @@ -457,28 +466,64 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } 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 - } + 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(key: String, fetch: () async throws -> T) async throws -> T { diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift index 1300a18..71ec96c 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -31,6 +31,15 @@ public actor OfflineCacheStore { return try? modelContext.fetch(descriptor).first?.payload } + /// Everything cached under a key prefix — e.g. every individually + /// cached document (`"document:"`) or collection + /// (`"collection:"`) after a Full Local Sync, for building a local + /// search index without a per-item exact-key lookup. + public func loadAll(keyPrefix: String) -> [Data] { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key.starts(with: keyPrefix) }) + return ((try? modelContext.fetch(descriptor)) ?? []).map(\.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. diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index e14af0f..3836b3c 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -421,8 +421,13 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testPerformFullSyncCachesEachDocumentIndividually() async throws { let stub = StubOutlineAPIClient() stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] } - stub.listDocumentsHandler = { _, _, offset, _ in - offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : [] + // Must return empty for any non-nil parentDocumentId (no children) — + // performFullSync now recurses into every document's own children, + // so a stub that ignores parentDocumentId and always returns the + // same root documents regardless would recurse into itself forever. + stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in + guard parentDocumentId == nil else { return [] } + return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : [] } let cache = try makeCache() let sut = CachingOutlineAPIClient(live: stub, cache: cache) @@ -437,6 +442,34 @@ final class CachingOutlineAPIClientTests: XCTestCase { XCTAssertEqual(cachedDoc.id, "doc-2") } + func testPerformFullSyncRecursesIntoNestedDocuments() async throws { + let stub = StubOutlineAPIClient() + stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] } + // doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild) — + // regression test for the real gap this fixed: only root-level + // documents were ever cached before, so a document's own + // sub-documents were never reachable offline at all unless + // something else happened to open them individually first. + stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in + guard offset == 0 else { return [] } + switch parentDocumentId { + case nil: return [self.makeDocument(id: "doc-1")] + case "doc-1": return [self.makeDocument(id: "doc-2")] + case "doc-2": return [self.makeDocument(id: "doc-3")] + default: return [] + } + } + let cache = try makeCache() + let sut = CachingOutlineAPIClient(live: stub, cache: cache) + + let summary = await sut.performFullSync() + + XCTAssertEqual(summary.documentsCount, 3) + stub.documentInfoHandler = { _ in throw StubTransportError() } + let cachedGrandchild = try await sut.documentInfo(id: "doc-3") + XCTAssertEqual(cachedGrandchild.id, "doc-3") + } + // MARK: - Offline document creation func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {