Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Window/WindowMessage.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

290 lines
11 KiB
Swift

import Foundation
/// Client-side send state of an optimistic user message. The web reference
/// extends the wire `DecryptedMessage` with `status?: MessageStatus`
/// (`web/src/types/api.ts`); the wire never carries it — servers echo rows
/// without a status and the client re-attaches it during merge.
public enum MessageStatus: String, Codable, Sendable {
case queued
case sending
case sent
case failed
}
/// Wire tri-state of `invokedAt` (`pagination.md` "Queued semantics"):
/// an absent key means already-invoked (pre-V8 hubs omit the field), an
/// explicit `null` means still queued, a number is the invocation time.
/// The strict-null queued check and the fixture projection both need the
/// three states kept apart, which the collapsed `DecryptedMessage.invokedAt`
/// (`Int?`) cannot do.
public enum InvokedAtField: Equatable, Sendable {
case absent
case null
case number(Int)
/// Collapsed JS view (`invokedAt ?? …` semantics): the number, or `nil`
/// for both `null` and absent.
public var numberValue: Int? {
if case .number(let value) = self { return value }
return nil
}
/// JS `invokedAt !== undefined` — the wire carried the key.
public var isPresent: Bool { self != .absent }
}
/// One row of the message window: the wire `DecryptedMessage` fields plus the
/// client-side ``status``. Mirrors the web's `DecryptedMessage & {status?}`.
///
/// Deliberately a **class**: `applyLatestResponse`'s request-baseline
/// comparison is by reference (web `!==`, Android object identity), so rows
/// need identity, and transitions must only create new instances for rows
/// they actually change — which every function in this package does.
/// ``Equatable`` is therefore identity (`===`) as well: two deep-equal rows
/// are different instances on purpose (that difference is what classifies a
/// row as "changed since the request left" during a reset replace).
///
/// Instances are immutable (`let` storage), so the class is safely `Sendable`.
public final class WindowMessage: Sendable, Equatable {
// MARK: Wire fields (`DecryptedMessageSchema`, with tri-state invokedAt)
/// Server uuid. Optimistic rows use the client `localId` until echoed.
public let id: String
/// Per-session insert counter; `nil` on optimistic rows.
public let seq: Int?
/// Client-generated id for optimistic reconciliation.
public let localId: String?
/// Role-wrapped envelope, kept wire-verbatim.
public let content: JSONValue
/// Hub receive time (epoch ms).
public let createdAt: Int
/// Invocation time, kept tri-state (see ``InvokedAtField``).
public let invokedAt: InvokedAtField
/// Future-scheduled send time (epoch ms), when set.
public let scheduledAt: Int?
// MARK: Client-side
public let status: MessageStatus?
public init(
id: String,
seq: Int? = nil,
localId: String? = nil,
content: JSONValue = .null,
createdAt: Int,
invokedAt: InvokedAtField = .absent,
scheduledAt: Int? = nil,
status: MessageStatus? = nil
) {
self.id = id
self.seq = seq
self.localId = localId
self.content = content
self.createdAt = createdAt
self.invokedAt = invokedAt
self.scheduledAt = scheduledAt
self.status = status
}
/// Wrap a wire row. `DecryptedMessage` collapses the invokedAt tri-state
/// to `Int?`; `nil` maps to an **explicit null** here because V8+ hubs
/// always send the key (see the caveat on `DecryptedMessage.invokedAt` —
/// if pre-V8 hub support is ever needed, the wire model must learn the
/// tri-state and this initializer picks it up).
public convenience init(wire: DecryptedMessage, status: MessageStatus? = nil) {
self.init(
id: wire.id,
seq: wire.seq,
localId: wire.localId,
content: wire.content,
createdAt: wire.createdAt,
invokedAt: wire.invokedAt.map(InvokedAtField.number) ?? .null,
scheduledAt: wire.scheduledAt,
status: status
)
}
/// Identity equality — see the type comment.
public static func == (lhs: WindowMessage, rhs: WindowMessage) -> Bool {
lhs === rhs
}
// MARK: Derived
/// Collapsed invocation time (JS `invokedAt ?? …` operand).
public var invokedAtNumber: Int? { invokedAt.numberValue }
/// Position time `invokedAt ?? createdAt` (`pagination.md` "Position key").
public var positionAt: Int { invokedAt.numberValue ?? createdAt }
/// JS `message.invokedAt === null`.
public var hasExplicitNullInvokedAt: Bool { invokedAt == .null }
/// A row is optimistic iff it has a localId and `id === localId`
/// (web `optimisticMessage`).
public var isOptimistic: Bool {
localId != nil && id == localId
}
/// Web `isUserMessage` (`web/src/lib/messages.ts`): the content envelope
/// is an object whose `role` is the string `'user'`.
public var isUserMessage: Bool {
content.objectValue?["role"]?.stringValue == "user"
}
/// Web `isQueuedForInvocation`: a user message whose `invokedAt` is an
/// explicit `null` and whose send did not fail. Only these rows sit in
/// the queued bar and survive window trims.
public var isQueuedForInvocation: Bool {
isUserMessage && hasExplicitNullInvokedAt && status != .failed
}
// MARK: Copies (each returns a NEW instance — identity change is meaningful)
/// Copy with `invokedAt` stamped to an explicit number.
public func withInvokedAt(_ invokedAt: Int) -> WindowMessage {
WindowMessage(
id: id, seq: seq, localId: localId, content: content,
createdAt: createdAt, invokedAt: .number(invokedAt),
scheduledAt: scheduledAt, status: status
)
}
/// Copy with the client-side status replaced.
public func withStatus(_ status: MessageStatus) -> WindowMessage {
WindowMessage(
id: id, seq: seq, localId: localId, content: content,
createdAt: createdAt, invokedAt: invokedAt,
scheduledAt: scheduledAt, status: status
)
}
/// The chat pipeline's collapsed wire view of this row (feeds
/// `normalizeDecryptedMessage`).
public var asDecryptedMessage: DecryptedMessage {
DecryptedMessage(
id: id,
seq: seq,
localId: localId,
content: content,
createdAt: createdAt,
invokedAt: invokedAt.numberValue,
scheduledAt: scheduledAt
)
}
}
// MARK: - Codable (wire object plus an optional `status` key)
/// Serializes as the wire object plus an optional `status` key — the same
/// shape the web persists (and the pagination fixtures use for op inputs).
/// `invokedAt` round-trips the tri-state: key omitted when absent, `null`
/// when explicit null.
extension WindowMessage: Codable {
private enum CodingKeys: String, CodingKey {
case id, seq, localId, content, createdAt, invokedAt, scheduledAt, status
}
public convenience init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let invokedAt: InvokedAtField
if container.contains(.invokedAt) {
if try container.decodeNil(forKey: .invokedAt) {
invokedAt = .null
} else {
invokedAt = .number(try container.decode(Int.self, forKey: .invokedAt))
}
} else {
invokedAt = .absent
}
self.init(
id: try container.decode(String.self, forKey: .id),
seq: try container.decodeIfPresent(Int.self, forKey: .seq),
localId: try container.decodeIfPresent(String.self, forKey: .localId),
content: try container.decodeIfPresent(JSONValue.self, forKey: .content) ?? .null,
createdAt: try container.decode(Int.self, forKey: .createdAt),
invokedAt: invokedAt,
scheduledAt: try container.decodeIfPresent(Int.self, forKey: .scheduledAt),
// Unknown status strings degrade to nil (Android `fromWire`) —
// a new client-side state must not break snapshot hydration.
status: (try container.decodeIfPresent(String.self, forKey: .status))
.flatMap(MessageStatus.init(rawValue:))
)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
// Optional-encoding writes explicit `null`s for seq/localId, matching
// the wire rows the hub emits.
try container.encode(seq, forKey: .seq)
try container.encode(localId, forKey: .localId)
try container.encode(content, forKey: .content)
try container.encode(createdAt, forKey: .createdAt)
switch invokedAt {
case .absent:
break
case .null:
try container.encodeNil(forKey: .invokedAt)
case .number(let value):
try container.encode(value, forKey: .invokedAt)
}
try container.encodeIfPresent(scheduledAt, forKey: .scheduledAt)
try container.encodeIfPresent(status?.rawValue, forKey: .status)
}
}
// MARK: - Optimistic row construction
/// Builds the optimistic row appended on send, mirroring
/// `createOptimisticMessage` in `web/src/hooks/mutations/useSendMessage.ts`
/// and the contract's "Optimistic sends" lifecycle: `id = localId`,
/// `seq = null`, explicit `invokedAt: null` (so the strict-null queued check
/// matches), content `{role:'user', content:{type:'text', text, attachments?},
/// meta:{deliveryMode}}`.
public func buildOptimisticMessage(
localId: String,
text: String,
createdAt: Int,
attachments: [AttachmentMetadata]? = nil,
scheduledAt: Int? = nil,
deliveryMode: String = "queue",
status: MessageStatus = .sending
) -> WindowMessage {
var inner: [String: JSONValue] = [
"type": .string("text"),
"text": .string(text),
]
if let attachments {
inner["attachments"] = .array(attachments.map { attachment in
var object: [String: JSONValue] = [
"id": .string(attachment.id),
"filename": .string(attachment.filename),
"mimeType": .string(attachment.mimeType),
"size": .number(Double(attachment.size)),
"path": .string(attachment.path),
]
if let previewUrl = attachment.previewUrl {
object["previewUrl"] = .string(previewUrl)
}
return .object(object)
})
}
let content: JSONValue = .object([
"role": .string("user"),
"content": .object(inner),
"meta": .object(["deliveryMode": .string(deliveryMode)]),
])
return WindowMessage(
id: localId,
seq: nil,
localId: localId,
content: content,
createdAt: createdAt,
invokedAt: .null,
scheduledAt: scheduledAt,
status: status
)
}