Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Stores/MessagesProviding.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

63 lines
2.8 KiB
Swift

import Foundation
import HapiProtocol
/// One `GET /api/sessions/:id/messages` request. The three variants are the
/// only shapes the reference client ever issues
/// (`docs/api/client-contract/pagination.md`): the window controller cannot
/// produce an invalid parameter combination by construction, and the
/// pagination fixture harness asserts these against the recorded
/// `expectedRequests` — `.after` always carries the `until` keys (explicitly
/// null on the first catch-up request), the other variants never do.
/// Mirror of the Android port's `MessagesQuery` sealed interface
/// (`app/hapi/data/api/MessagesApi.kt`).
public enum MessagesPageQuery: Equatable, Sendable {
/// Newest page, no cursor.
case latest(limit: Int)
/// Rows strictly older than the compound cursor.
case before(beforeAt: Int, beforeSeq: Int, limit: Int)
/// Tail catch-up: rows strictly newer than the cursor, snapshot-bounded.
case after(afterAt: Int, afterSeq: Int, untilAt: Int?, untilSeq: Int?, epoch: Int, limit: Int)
/// The flat REST query (`MessagesQuerySchema`) this variant serializes
/// to. Null `until` halves are omitted from the URL, like the web client.
public var restQuery: MessagesQuery {
switch self {
case .latest(let limit):
return .latest(limit: limit)
case .before(let beforeAt, let beforeSeq, let limit):
return .before(seq: beforeSeq, at: beforeAt, limit: limit)
case .after(let afterAt, let afterSeq, let untilAt, let untilSeq, let epoch, let limit):
return .after(
seq: afterSeq,
at: afterAt,
epoch: epoch,
untilSeq: untilSeq,
untilAt: untilAt,
limit: limit
)
}
}
}
/// Minimal seam over the two message endpoints the window controller drives —
/// implemented by ``APIClient`` in production and by a scripted fake in the
/// pagination fixture harness. Extracted (instead of depending on the
/// concrete client) so the REAL orchestration is what the fixtures replay.
/// Mirror of the Android port's `MessagesApi`.
public protocol MessagesProviding: Sendable {
/// `GET /api/sessions/:id/messages`.
func messages(sessionId: String, query: MessagesPageQuery) async throws -> MessagesResponse
/// `POST /api/sessions/:id/messages/queued-state`.
func queuedState(sessionId: String, localIds: [String]) async throws -> QueuedStateResponse
}
extension APIClient: MessagesProviding {
public func messages(sessionId: String, query: MessagesPageQuery) async throws -> MessagesResponse {
try await messages(sessionId: sessionId, query: query.restQuery)
}
// `queuedState(sessionId:localIds:)` from Endpoints/MessageEndpoints.swift
// already satisfies the second requirement.
}