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.
105 lines
4.5 KiB
Swift
105 lines
4.5 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it
|
|
/// last fetched successfully, keyed by request shape, so browsing keeps
|
|
/// working (stale) once the network stops. Also owns the offline write
|
|
/// queue (`PendingOperation`) — same store, same actor, since both need
|
|
/// synchronized access to the same on-disk SwiftData container.
|
|
@ModelActor
|
|
public actor OfflineCacheStore {
|
|
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
|
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
|
|
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
|
|
}
|
|
|
|
// MARK: - Read-through cache
|
|
|
|
public func save(_ data: Data, forKey key: String) {
|
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
|
if let existing = try? modelContext.fetch(descriptor).first {
|
|
existing.payload = data
|
|
existing.cachedAt = Date()
|
|
} else {
|
|
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
|
|
public func load(forKey key: String) -> Data? {
|
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
|
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 {
|
|
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
|
}
|
|
|
|
public func totalBytes() -> Int {
|
|
let descriptor = FetchDescriptor<CachedPayload>()
|
|
let rows = (try? modelContext.fetch(descriptor)) ?? []
|
|
return rows.reduce(0) { $0 + $1.payload.count }
|
|
}
|
|
|
|
public func clearAll() {
|
|
let descriptor = FetchDescriptor<CachedPayload>()
|
|
guard let rows = try? modelContext.fetch(descriptor) else { return }
|
|
rows.forEach { modelContext.delete($0) }
|
|
try? modelContext.save()
|
|
}
|
|
|
|
// MARK: - Offline write queue
|
|
|
|
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
|
/// onto a still-unsynced edit, or a create being cancelled by its own
|
|
/// synthesized id) replaces the row in place rather than piling up.
|
|
public func enqueueOperation(id: String, kind: String, payload: Data) {
|
|
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
|
if let existing = try? modelContext.fetch(descriptor).first {
|
|
existing.kind = kind
|
|
existing.payload = payload
|
|
existing.lastError = nil
|
|
existing.attemptCount = 0
|
|
} else {
|
|
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
|
|
public func pendingOperations() -> [PendingOperation] {
|
|
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
|
|
return (try? modelContext.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
/// Returns whether a matching operation was actually found and removed —
|
|
/// callers use this to detect "this targeted something that never made
|
|
/// it to the server in the first place" and skip queuing a delete.
|
|
@discardableResult
|
|
public func removeOperation(id: String) -> Bool {
|
|
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
|
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
|
|
modelContext.delete(existing)
|
|
try? modelContext.save()
|
|
return true
|
|
}
|
|
|
|
public func recordFailure(id: String, error: String) {
|
|
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
|
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
|
existing.lastAttemptAt = Date()
|
|
existing.lastError = error
|
|
existing.attemptCount += 1
|
|
try? modelContext.save()
|
|
}
|
|
}
|