Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Stores/MessageWindowControllers.swift
T
weishu 7712a92f51 feat(ios): message window store port + pagination fixture harness (A-M2d)
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).
2026-08-17 20:18:05 +08:00

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()
}
}