mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
HapiProtocol/Window/ — pure port of web/src/lib/message-window-store.ts + messages.ts, mirroring the Android reference port 1:1: - MessageWindowState: constants (400/600/800/800/200), MessagePosition, OlderLoadOutcome, full InternalState fields, persisted v2 snapshot shape - MessageWindowLogic: every transition (merge/trim preserving queued rows, latest replace with request-baseline identity preservation, tail sync begin/apply/finish, older pages + epoch-mismatch reset, enterTailMode that deliberately keeps requiresLatestReset, activate, SSE ingest with hidden-row cursor advance, markConsumed stamping server rows to 'sent', optimistic lifecycle, queued reconcile, hydrate/persist, seededState) - MessageMerge: position comparator (ASCII tie-break), localId echo replacement preserving status/invokedAt, 10s sent-dedup fallback - MessageRetention: calls the fixtures-green chat pipeline's normalizeDecryptedMessage directly (no hand-mirrored tree to drift) - WindowMessage: identity-carrying final class (web's !== baseline classification), tri-state invokedAt, buildOptimisticMessage HapiClient/Stores/ — async half: - MessagesProviding seam (three-variant MessagesPageQuery; APIClient conforms via its existing endpoints) - MessageWindowController actor: single-flight tail controller with trailing drain and a synchronous generation bump before the first await (the web runs to its first suspension; Android used UNDISPATCHED), fetchOlder with onBeforeApply veto, SSE hooks, optimistic append/status/ cancel-invoked, queued-state reconciliation in 1000-id batches, seedFrom - WindowSnapshotStore: per-session JSON snapshots, LRU 10 (dedup TODO with the session-list package's cache) + MessageWindowControllers registry Tests (swift-testing): - PaginationFixtureTests: parameterized replay of shared/fixtures/ pagination/*.json against the real controller via a scripted provider; asserts expectedRequests (canonical JSON incl. explicit-null untils), expectedOutcome, expectedCandidates, and the final expectedState projection with per-op labels and first-differing-line diffs - MessageWindowControllerTests: tail-sync coalescing + trailing drain, concurrent-SSE preservation across a reset replace, cursor-no-advance guard, snapshot round-trip/LRU, seedFrom All 11 fixtures verified green against a line-by-line Python mirror of these exact algorithms (no local Swift toolchain; CI runs the real suite).
59 lines
2.3 KiB
Swift
59 lines
2.3 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// Per-hub registry of per-session ``MessageWindowController``s — the
|
|
/// analogue of the web module's session-keyed maps and the Android port's
|
|
/// `MessageWindowStores`. ``open(sessionId:)`` hydrates a cold session from
|
|
/// its disk snapshot (interrupted `sending` rows restore, stale snapshots
|
|
/// start flagged for a latest reset); ``seed(fromSessionId:toSessionId:)``
|
|
/// carries a window across a resume/reopen id change. Snapshot files
|
|
/// themselves are LRU-capped by ``WindowSnapshotStore``.
|
|
public actor MessageWindowControllers {
|
|
private let provider: any MessagesProviding
|
|
private let snapshots: WindowSnapshotStore?
|
|
private var controllers: [String: MessageWindowController] = [:]
|
|
|
|
public init(provider: any MessagesProviding, snapshots: WindowSnapshotStore? = nil) {
|
|
self.provider = provider
|
|
self.snapshots = snapshots
|
|
}
|
|
|
|
/// The session's controller, hydrating from its snapshot on first open.
|
|
public func open(sessionId: String) -> MessageWindowController {
|
|
if let existing = controllers[sessionId] {
|
|
return existing
|
|
}
|
|
let hydrated = snapshots?.load(sessionId: sessionId).map {
|
|
MessageWindowLogic.hydrate(sessionId: sessionId, persisted: $0)
|
|
}
|
|
let controller = MessageWindowController(
|
|
sessionId: sessionId,
|
|
provider: provider,
|
|
snapshots: snapshots,
|
|
initialState: hydrated
|
|
)
|
|
controllers[sessionId] = controller
|
|
return controller
|
|
}
|
|
|
|
/// Already-open controller, if any (no hydration).
|
|
public func peek(sessionId: String) -> MessageWindowController? {
|
|
controllers[sessionId]
|
|
}
|
|
|
|
/// Web `seedMessageWindowFromSession` for resume/reopen id migration.
|
|
public func seed(fromSessionId: String, toSessionId: String) async {
|
|
guard !fromSessionId.isEmpty, !toSessionId.isEmpty, fromSessionId != toSessionId else {
|
|
return
|
|
}
|
|
let source = open(sessionId: fromSessionId)
|
|
let target = open(sessionId: toSessionId)
|
|
await target.seedFrom(source)
|
|
}
|
|
|
|
/// Web `clearMessageWindow` (e.g. on `session-removed`).
|
|
public func clear(sessionId: String) async {
|
|
await controllers[sessionId]?.clear()
|
|
}
|
|
}
|