mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-11 02:11:37 +00:00
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices. Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
185 lines
8.5 KiB
Swift
185 lines
8.5 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
import Observation
|
|
|
|
public struct PendingRead: Codable, Equatable, Sendable {
|
|
public var id: UUID
|
|
public var request: SessionReadStateRequest
|
|
}
|
|
|
|
/// Durable read revisions and outbox, isolated by hub directory and namespace.
|
|
public struct LastSeenState: Codable, Equatable, Sendable {
|
|
public var lastSeen: [String: Int] = [:]
|
|
public var server: [String: SessionReadState] = [:]
|
|
public var pending: [String: PendingRead] = [:]
|
|
public init() {}
|
|
}
|
|
|
|
private actor ReadStateWriter {
|
|
func write(_ data: Data, to url: URL) throws {
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try data.write(to: url, options: .atomic)
|
|
}
|
|
}
|
|
|
|
@MainActor @Observable
|
|
public final class LastSeenStore {
|
|
public private(set) var state: LastSeenState
|
|
public private(set) var persistenceError: String?
|
|
@ObservationIgnored private let directory: URL?
|
|
@ObservationIgnored private var namespace: String?
|
|
@ObservationIgnored private let writer = ReadStateWriter()
|
|
@ObservationIgnored private var mutationTask: Task<Void, Never>?
|
|
@ObservationIgnored private var mutationToken: UUID?
|
|
@ObservationIgnored private var syncTask: Task<Void, Never>?
|
|
@ObservationIgnored private var syncToken: UUID?
|
|
@ObservationIgnored private var sender: (@Sendable (String, SessionReadStateRequest) async throws -> SessionReadState)?
|
|
@ObservationIgnored private var foreground = true
|
|
@ObservationIgnored private var closed = false
|
|
public var isPersisting: Bool { mutationTask != nil }
|
|
|
|
public init(snapshotDirectory: URL? = nil, namespace: String? = "default") {
|
|
self.directory = snapshotDirectory
|
|
self.namespace = namespace
|
|
self.state = Self.load(directory: snapshotDirectory, namespace: namespace)
|
|
}
|
|
|
|
private static func file(directory: URL?, namespace: String?) -> URL? {
|
|
guard let directory, let namespace else { return nil }
|
|
let name = namespace.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? ""
|
|
return directory.appendingPathComponent("read-state-\(name).json")
|
|
}
|
|
|
|
private static func load(directory: URL?, namespace: String?) -> LastSeenState {
|
|
guard let url = file(directory: directory, namespace: namespace),
|
|
let data = try? Data(contentsOf: url),
|
|
let state = try? HapiJSON.decoder.decode(LastSeenState.self, from: data) else { return LastSeenState() }
|
|
return state
|
|
}
|
|
|
|
public func configure(namespace: String, sender: @escaping @Sendable (String, SessionReadStateRequest) async throws -> SessionReadState) async {
|
|
guard !closed else { return }
|
|
await flushPersistence()
|
|
if self.namespace != namespace {
|
|
syncTask?.cancel()
|
|
syncTask = nil
|
|
syncToken = nil
|
|
self.namespace = namespace
|
|
state = Self.load(directory: directory, namespace: namespace)
|
|
}
|
|
self.sender = sender
|
|
sync()
|
|
}
|
|
|
|
public func setForeground(_ value: Bool) {
|
|
foreground = value
|
|
if value { sync() }
|
|
}
|
|
|
|
public func shutdown() { closed = true; foreground = false; syncTask?.cancel(); syncTask = nil; syncToken = nil }
|
|
|
|
public func lastSeenAt(_ sessionId: String) -> Int { state.lastSeen[sessionId] ?? 0 }
|
|
|
|
/// Every successful mark is already persisted; this waits for in-flight writes.
|
|
public func flushPersistence() async { await mutationTask?.value }
|
|
|
|
private func commit(namespace expectedNamespace: String? = nil, _ transform: @escaping @MainActor (LastSeenState) -> LastSeenState) async {
|
|
let previous = mutationTask
|
|
let identity = expectedNamespace ?? namespace
|
|
let token = UUID()
|
|
mutationToken = token
|
|
let task = Task { @MainActor in
|
|
await previous?.value
|
|
guard self.namespace == identity else { return }
|
|
let next = transform(self.state)
|
|
guard next != self.state else { return }
|
|
do {
|
|
if let url = Self.file(directory: self.directory, namespace: identity) {
|
|
let data = try HapiJSON.encoder.encode(next)
|
|
try await self.writer.write(data, to: url)
|
|
}
|
|
guard self.namespace == identity else { return }
|
|
self.state = next
|
|
self.persistenceError = nil
|
|
} catch { self.persistenceError = error.localizedDescription }
|
|
}
|
|
mutationTask = task
|
|
await task.value
|
|
if mutationToken == token { mutationTask = nil; mutationToken = nil }
|
|
}
|
|
|
|
public func markSeen(sessionId: String, revision: Int, serverState: SessionReadState? = nil) async {
|
|
guard !closed, namespace != nil, !sessionId.isEmpty, revision >= 0 else { return }
|
|
await commit { current in
|
|
let server = SessionReadState.merge(current.server[sessionId], serverState) ?? SessionReadState()
|
|
let seen = max(current.lastSeen[sessionId] ?? 0, server.readRevision)
|
|
let manual = server.manualUnread && current.pending[sessionId]?.request.expectedVersion != server.version
|
|
guard seen < revision || manual else { return current }
|
|
var next = current
|
|
next.lastSeen[sessionId] = max(seen, revision)
|
|
next.server[sessionId] = server
|
|
next.pending[sessionId] = PendingRead(id: UUID(), request: SessionReadStateRequest(
|
|
readRevision: max(seen, revision), expectedVersion: serverState?.version ?? server.version
|
|
))
|
|
return next
|
|
}
|
|
sync()
|
|
}
|
|
|
|
private func sync() {
|
|
guard !closed, foreground, syncTask == nil, !state.pending.isEmpty, let sender else { return }
|
|
let identity = namespace
|
|
let token = UUID()
|
|
syncToken = token
|
|
syncTask = Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
defer {
|
|
if self.syncToken == token {
|
|
self.syncTask = nil
|
|
self.syncToken = nil
|
|
if !Task.isCancelled { self.sync() }
|
|
}
|
|
}
|
|
while !Task.isCancelled && self.foreground && self.namespace == identity {
|
|
guard let (id, pending) = self.state.pending.first else { break }
|
|
do {
|
|
let response = try await sender(id, pending.request)
|
|
guard !Task.isCancelled, self.namespace == identity else { break }
|
|
await self.commit(namespace: identity) { current in
|
|
var next = current
|
|
next.server[id] = SessionReadState.merge(current.server[id], response)
|
|
if next.pending[id]?.id == pending.id { next.pending[id] = nil }
|
|
return next
|
|
}
|
|
if self.persistenceError != nil { try await Task.sleep(for: .seconds(5)) }
|
|
} catch is CancellationError { break
|
|
} catch let error as APIError where [400, 403, 404, 409].contains(error.status) {
|
|
guard !Task.isCancelled, self.namespace == identity else { break }
|
|
let authoritative = error.body?.data(using: .utf8).flatMap {
|
|
try? HapiJSON.decoder.decode(SessionReadStateResponse.self, from: $0).readState
|
|
}
|
|
await self.commit(namespace: identity) { current in
|
|
var next = current
|
|
next.server[id] = SessionReadState.merge(current.server[id], authoritative)
|
|
if next.pending[id]?.id == pending.id {
|
|
next.pending[id] = nil
|
|
next.lastSeen[id] = next.server[id]?.readRevision ?? 0
|
|
}
|
|
return next
|
|
}
|
|
if self.persistenceError != nil { try? await Task.sleep(for: .seconds(5)) }
|
|
} catch {
|
|
do { try await Task.sleep(for: .seconds(5)) } catch { break }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public static func isUnread(_ summary: SessionSummary, state: LastSeenState) -> Bool {
|
|
let server = SessionReadState.merge(state.server[summary.id], summary.readState) ?? SessionReadState()
|
|
let manual = server.manualUnread && state.pending[summary.id]?.request.expectedVersion != server.version
|
|
return manual || (!(summary.active && summary.thinking)
|
|
&& (summary.contentRevision ?? 0) > max(server.readRevision, state.lastSeen[summary.id] ?? 0))
|
|
}
|
|
}
|