mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +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).
157 lines
7.0 KiB
Swift
157 lines
7.0 KiB
Swift
import Foundation
|
|
|
|
/// Merge/order semantics for window rows — a faithful port of
|
|
/// `web/src/lib/messages.ts` (`compareMessages`, `mergeMessages`), matching
|
|
/// the Android reference port (`window/MessageMerge.kt`) one-to-one.
|
|
public enum MessageMerge {
|
|
|
|
/// Position comparator: ascending `at = invokedAt ?? createdAt`, ties by
|
|
/// `seq`, full ties by ASCII (UTF-16 code unit) id comparison. The web
|
|
/// uses `localeCompare` for the final tie-break, but the fixtures keep
|
|
/// every position pair distinct and the fixtures README pins ASCII
|
|
/// comparison for native ports (`shared/fixtures/README.md`, pagination
|
|
/// determinism notes).
|
|
public static func compare(_ a: WindowMessage, _ b: WindowMessage) -> Int {
|
|
let aTime = a.invokedAtNumber ?? a.createdAt
|
|
let bTime = b.invokedAtNumber ?? b.createdAt
|
|
if aTime != bTime { return aTime < bTime ? -1 : 1 }
|
|
if let aSeq = a.seq, let bSeq = b.seq, aSeq != bSeq {
|
|
return aSeq < bSeq ? -1 : 1
|
|
}
|
|
if a.id == b.id { return 0 }
|
|
return a.id.utf16.lexicographicallyPrecedes(b.id.utf16) ? -1 : 1
|
|
}
|
|
|
|
/// Stable sort by ``compare(_:_:)`` — JS `Array.prototype.sort` is
|
|
/// specified stable; Swift's `sorted` is not documented stable, so ties
|
|
/// (only possible for duplicate ids in raw input) are pinned by index.
|
|
static func sortedByPosition(_ messages: [WindowMessage]) -> [WindowMessage] {
|
|
messages.enumerated()
|
|
.sorted { lhs, rhs in
|
|
let comparison = compare(lhs.element, rhs.element)
|
|
return comparison != 0 ? comparison < 0 : lhs.offset < rhs.offset
|
|
}
|
|
.map(\.element)
|
|
}
|
|
|
|
/// Merge `incoming` into `existing`:
|
|
/// - by-id upsert (incoming wins — the incoming **instance** replaces the
|
|
/// row even when content-identical, which is what classifies it as
|
|
/// changed for the reset baseline; a numeric `invokedAt` already known
|
|
/// locally is preserved when the incoming copy lacks one);
|
|
/// - stored rows carrying a `localId` evict the optimistic bubble with
|
|
/// the same `localId`, inheriting its client `status` (when the server
|
|
/// row has none) and any locally-known numeric `invokedAt`;
|
|
/// - fallback: an optimistic row already `sent` is dropped when a server
|
|
/// user message sits within 10 s of the same position;
|
|
/// - stable sort by the position comparator.
|
|
public static func mergeMessages(
|
|
_ existing: [WindowMessage],
|
|
_ incoming: [WindowMessage]
|
|
) -> [WindowMessage] {
|
|
if existing.isEmpty {
|
|
return sortedByPosition(incoming)
|
|
}
|
|
if incoming.isEmpty {
|
|
return sortedByPosition(existing)
|
|
}
|
|
|
|
// JS Map: first-insertion key order, later sets update in place.
|
|
var order: [String] = []
|
|
var byId: [String: WindowMessage] = [:]
|
|
for message in existing {
|
|
if byId.updateValue(message, forKey: message.id) == nil {
|
|
order.append(message.id)
|
|
}
|
|
}
|
|
for message in incoming {
|
|
let current = byId[message.id]
|
|
let replacement: WindowMessage
|
|
// JS `existing.invokedAt != null && msg.invokedAt == null` — keep
|
|
// a locally-known invocation stamp when the incoming copy has
|
|
// none (explicit null or absent).
|
|
if let current, let knownInvokedAt = current.invokedAtNumber, message.invokedAtNumber == nil {
|
|
replacement = message.withInvokedAt(knownInvokedAt)
|
|
} else {
|
|
replacement = message
|
|
}
|
|
if byId.updateValue(replacement, forKey: message.id) == nil {
|
|
order.append(message.id)
|
|
}
|
|
}
|
|
|
|
var merged: [WindowMessage] = order.map { byId[$0]! }
|
|
|
|
var incomingStoredLocalIds = Set<String>()
|
|
for message in incoming {
|
|
if let localId = message.localId, !message.isOptimistic {
|
|
incomingStoredLocalIds.insert(localId)
|
|
}
|
|
}
|
|
|
|
if !incomingStoredLocalIds.isEmpty {
|
|
var optimisticStatusByLocalId: [String: MessageStatus] = [:]
|
|
var optimisticInvokedAtByLocalId: [String: Int?] = [:]
|
|
for message in merged {
|
|
guard
|
|
let localId = message.localId,
|
|
message.isOptimistic,
|
|
incomingStoredLocalIds.contains(localId)
|
|
else { continue }
|
|
if let status = message.status {
|
|
optimisticStatusByLocalId[localId] = status
|
|
}
|
|
// JS `msg.invokedAt !== undefined` — record when the key was
|
|
// present, even as an explicit null. `updateValue` (not the
|
|
// subscript setter) so a nil VALUE still creates the entry,
|
|
// mirroring the JS Map.
|
|
if message.invokedAt.isPresent {
|
|
optimisticInvokedAtByLocalId.updateValue(message.invokedAtNumber, forKey: localId)
|
|
}
|
|
}
|
|
merged = merged.filter { message in
|
|
guard let localId = message.localId, incomingStoredLocalIds.contains(localId) else {
|
|
return true
|
|
}
|
|
return !message.isOptimistic
|
|
}
|
|
if !optimisticStatusByLocalId.isEmpty || !optimisticInvokedAtByLocalId.isEmpty {
|
|
merged = merged.map { message in
|
|
guard let localId = message.localId else { return message }
|
|
var updated = message
|
|
if let preservedStatus = optimisticStatusByLocalId[localId], message.status == nil {
|
|
updated = updated.withStatus(preservedStatus)
|
|
}
|
|
if message.invokedAtNumber == nil,
|
|
let recorded = optimisticInvokedAtByLocalId[localId],
|
|
let optimisticInvokedAt = recorded {
|
|
updated = updated.withInvokedAt(optimisticInvokedAt)
|
|
}
|
|
return updated
|
|
}
|
|
}
|
|
}
|
|
|
|
let optimisticMessages = merged.filter(\.isOptimistic)
|
|
let nonOptimisticMessages = merged.filter { !$0.isOptimistic }
|
|
var result = nonOptimisticMessages
|
|
|
|
for optimistic in optimisticMessages {
|
|
if optimistic.status == .sent {
|
|
// Compare by the position key (invokedAt ?? createdAt): a
|
|
// late ack can attach invokedAt long after createdAt, and
|
|
// matching on createdAt alone would render duplicates.
|
|
let optimisticTime = optimistic.invokedAtNumber ?? optimistic.createdAt
|
|
let hasServerUserMessage = nonOptimisticMessages.contains { message in
|
|
message.isUserMessage
|
|
&& abs((message.invokedAtNumber ?? message.createdAt) - optimisticTime) < 10_000
|
|
}
|
|
if hasServerUserMessage { continue }
|
|
}
|
|
result.append(optimistic)
|
|
}
|
|
|
|
return sortedByPosition(result)
|
|
}
|
|
}
|