mana-swift-event-sync/Sources/ManaEventSync/Core/EventEnvelope.swift
Till JS 19dced015c chore(lint): sequenceNumber-Fix lint-sauber (c→container, nesting-disable)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:44:42 +02:00

143 lines
5.9 KiB
Swift

import Foundation
/// Wire-Format eines Events auf der mana-event-sourced Plattform.
///
/// Spiegel von `EventEnvelope<Payload>` aus `@mana/event-kit`. Der
/// Server (`mana-sync`) kennt nur die Routing-Felder; `payload` ist
/// opaque JSON (ggf. `enc:1:`-verschlüsselt).
///
/// `sequenceNumber` + `receivedAt` werden Server-seitig beim Append
/// vergeben — beim Emit lokal `nil`, beim Pull gesetzt. `clientId` +
/// `payloadHash` sind für Wire-Parität optional vorgesehen; die Engine
/// stempelt sie beim Emit.
public struct EventEnvelope: Codable, Equatable, Sendable {
public let eventId: String
public let aggregateId: String
public let appId: String
public let eventType: String
public let eventVersion: Int
public let occurredAt: String
public var receivedAt: String?
public var sequenceNumber: Int64?
public var causationId: String?
public var correlationId: String?
public let actor: ActorContext
public var attributedToUserId: String
public let origin: String
public let idempotencyKey: String
public var clientId: String?
public var payloadHash: String?
public var payload: JSONValue
public init(
eventId: String,
aggregateId: String,
appId: String,
eventType: String,
eventVersion: Int,
occurredAt: String,
receivedAt: String? = nil,
sequenceNumber: Int64? = nil,
causationId: String? = nil,
correlationId: String? = nil,
actor: ActorContext,
attributedToUserId: String,
origin: String,
idempotencyKey: String,
clientId: String? = nil,
payloadHash: String? = nil,
payload: JSONValue
) {
self.eventId = eventId
self.aggregateId = aggregateId
self.appId = appId
self.eventType = eventType
self.eventVersion = eventVersion
self.occurredAt = occurredAt
self.receivedAt = receivedAt
self.sequenceNumber = sequenceNumber
self.causationId = causationId
self.correlationId = correlationId
self.actor = actor
self.attributedToUserId = attributedToUserId
self.origin = origin
self.idempotencyKey = idempotencyKey
self.clientId = clientId
self.payloadHash = payloadHash
self.payload = payload
}
enum CodingKeys: String, CodingKey {
case eventId, aggregateId, appId, eventType, eventVersion, occurredAt, receivedAt
case sequenceNumber, causationId, correlationId, actor, attributedToUserId
case origin, idempotencyKey, clientId, payloadHash, payload
}
/// Custom-Decode, weil `mana-sync` `sequenceNumber` als JSON-**String**
/// serialisiert (`json:"...,string"`). Default-Int64-Decode würde werfen
/// → ganzer Pull scheitert → Cross-Device-Sync still tot. Wir tolerieren
/// String **und** Zahl.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
eventId = try container.decode(String.self, forKey: .eventId)
aggregateId = try container.decode(String.self, forKey: .aggregateId)
appId = try container.decode(String.self, forKey: .appId)
eventType = try container.decode(String.self, forKey: .eventType)
eventVersion = try container.decode(Int.self, forKey: .eventVersion)
occurredAt = try container.decode(String.self, forKey: .occurredAt)
receivedAt = try container.decodeIfPresent(String.self, forKey: .receivedAt)
sequenceNumber = container.decodeFlexibleInt64(forKey: .sequenceNumber)
causationId = try container.decodeIfPresent(String.self, forKey: .causationId)
correlationId = try container.decodeIfPresent(String.self, forKey: .correlationId)
actor = try container.decode(ActorContext.self, forKey: .actor)
attributedToUserId = try container.decode(String.self, forKey: .attributedToUserId)
origin = try container.decode(String.self, forKey: .origin)
idempotencyKey = try container.decode(String.self, forKey: .idempotencyKey)
clientId = try container.decodeIfPresent(String.self, forKey: .clientId)
payloadHash = try container.decodeIfPresent(String.self, forKey: .payloadHash)
payload = try container.decode(JSONValue.self, forKey: .payload)
}
/// Kopie mit ersetztem Payload — genutzt von der Crypto-Pipeline
/// (encrypt vor Outbox, decrypt nach Pull).
public func withPayload(_ newPayload: JSONValue) -> EventEnvelope {
var copy = self
copy.payload = newPayload
return copy
}
/// Kopie mit neu attribuiertem User — genutzt vom `signIn`-Claim
/// (re-taggt `anon:*` → echter `sub`).
public func attributed(to userId: String) -> EventEnvelope {
var copy = self
copy.attributedToUserId = userId
return copy
}
}
/// Actor-Context — wer hat das Event ausgelöst.
public struct ActorContext: Codable, Equatable, Sendable {
public let kind: String // "user" | "system" | "ai"
public let principalId: String
public var displayName: String?
public init(kind: String, principalId: String, displayName: String? = nil) {
self.kind = kind
self.principalId = principalId
self.displayName = displayName
}
public static func user(principalId: String, displayName: String? = "ios") -> ActorContext {
ActorContext(kind: "user", principalId: principalId, displayName: displayName)
}
}
extension KeyedDecodingContainer {
/// Dekodiert ein optionales Int64 aus JSON-Zahl **oder** -String —
/// `mana-sync` sendet BIGINTs (`sequenceNumber`) als String.
func decodeFlexibleInt64(forKey key: Key) -> Int64? {
if let value = (try? decodeIfPresent(Int64.self, forKey: key)) ?? nil { return value }
if let str = (try? decodeIfPresent(String.self, forKey: key)) ?? nil { return Int64(str) }
return nil
}
}