- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
186 lines
6.3 KiB
Swift
186 lines
6.3 KiB
Swift
import Foundation
|
|
|
|
public enum RepositoryError: Error, Equatable, Sendable {
|
|
case invalidOrigin
|
|
case duplicateOrigin
|
|
case revisionConflict
|
|
case notFound
|
|
case databaseFailed
|
|
}
|
|
|
|
public struct Instance: Codable, Equatable, Identifiable, Sendable {
|
|
public let id: UUID
|
|
public var name: String
|
|
/// Where this node is reached from. Direct on the local network, or through a relay.
|
|
///
|
|
/// This replaced a non-optional `origin: URL`. The migration in `init(from:)` reads the old
|
|
/// shape so an existing node list upgrades without being reported as duplicate, and every write
|
|
/// emits only the new shape.
|
|
public var endpoint: InstanceEndpoint
|
|
public var tags: [String]
|
|
public var credentialConfigured: Bool
|
|
public var configRevision: Int
|
|
public let createdAt: Date
|
|
public var updatedAt: Date
|
|
|
|
public init(
|
|
id: UUID,
|
|
name: String,
|
|
endpoint: InstanceEndpoint,
|
|
tags: [String] = [],
|
|
credentialConfigured: Bool = false,
|
|
configRevision: Int = 1,
|
|
createdAt: Date,
|
|
updatedAt: Date
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.endpoint = endpoint
|
|
self.tags = tags
|
|
self.credentialConfigured = credentialConfigured
|
|
self.configRevision = configRevision
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
/// Convenience for the direct case, which is what most call sites mean.
|
|
public init(
|
|
id: UUID,
|
|
name: String,
|
|
origin: URL,
|
|
tags: [String] = [],
|
|
credentialConfigured: Bool = false,
|
|
configRevision: Int = 1,
|
|
createdAt: Date,
|
|
updatedAt: Date
|
|
) {
|
|
self.init(
|
|
id: id,
|
|
name: name,
|
|
endpoint: .direct(origin),
|
|
tags: tags,
|
|
credentialConfigured: credentialConfigured,
|
|
configRevision: configRevision,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt
|
|
)
|
|
}
|
|
|
|
/// The direct origin, when this instance has one.
|
|
public var origin: URL? { endpoint.directOrigin }
|
|
|
|
public var isRelay: Bool { endpoint.isRelay }
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, name, endpoint, origin, tags, credentialConfigured, configRevision, createdAt, updatedAt
|
|
}
|
|
|
|
/// Decodes the new shape, falling back to the pre-relay `origin` field.
|
|
///
|
|
/// The fallback is what makes the upgrade invisible: an app that dropped support for the old key
|
|
/// would fail to decode the whole store and lose every node. Note that `origin` is decoded
|
|
/// leniently as a string and parsed here rather than decoded as a `URL`, because the encoder
|
|
/// that wrote it may have produced a spelling `URL` will not round-trip from a bare string in
|
|
/// every case.
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(UUID.self, forKey: .id)
|
|
name = try container.decode(String.self, forKey: .name)
|
|
if let endpoint = try container.decodeIfPresent(InstanceEndpoint.self, forKey: .endpoint) {
|
|
self.endpoint = endpoint
|
|
} else if let origin = try container.decodeIfPresent(URL.self, forKey: .origin) {
|
|
self.endpoint = .direct(origin)
|
|
} else {
|
|
throw DecodingError.keyNotFound(
|
|
CodingKeys.endpoint,
|
|
DecodingError.Context(
|
|
codingPath: container.codingPath,
|
|
debugDescription: "an instance must carry either endpoint or the legacy origin"
|
|
)
|
|
)
|
|
}
|
|
tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
|
|
credentialConfigured = try container.decodeIfPresent(Bool.self, forKey: .credentialConfigured) ?? false
|
|
configRevision = try container.decodeIfPresent(Int.self, forKey: .configRevision) ?? 1
|
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(id, forKey: .id)
|
|
try container.encode(name, forKey: .name)
|
|
try container.encode(endpoint, forKey: .endpoint)
|
|
try container.encode(tags, forKey: .tags)
|
|
try container.encode(credentialConfigured, forKey: .credentialConfigured)
|
|
try container.encode(configRevision, forKey: .configRevision)
|
|
try container.encode(createdAt, forKey: .createdAt)
|
|
try container.encode(updatedAt, forKey: .updatedAt)
|
|
}
|
|
}
|
|
|
|
public struct InstanceInput: Sendable {
|
|
public var name: String
|
|
public var endpoint: InstanceEndpoint
|
|
public var tags: [String]
|
|
public var password: String?
|
|
|
|
public init(
|
|
name: String,
|
|
endpoint: InstanceEndpoint,
|
|
tags: [String] = [],
|
|
password: String? = nil
|
|
) {
|
|
self.name = name
|
|
self.endpoint = endpoint
|
|
self.tags = tags
|
|
self.password = password
|
|
}
|
|
|
|
public init(
|
|
name: String,
|
|
origin: URL,
|
|
tags: [String] = [],
|
|
password: String? = nil
|
|
) {
|
|
self.init(name: name, endpoint: .direct(origin), tags: tags, password: password)
|
|
}
|
|
}
|
|
|
|
public struct InstancePatch: Sendable {
|
|
public var name: String?
|
|
public var endpoint: InstanceEndpoint?
|
|
public var tags: [String]?
|
|
public var password: String?
|
|
|
|
public init(
|
|
name: String? = nil,
|
|
endpoint: InstanceEndpoint? = nil,
|
|
tags: [String]? = nil,
|
|
password: String? = nil
|
|
) {
|
|
self.name = name
|
|
self.endpoint = endpoint
|
|
self.tags = tags
|
|
self.password = password
|
|
}
|
|
}
|
|
|
|
public struct InstanceQuery: Sendable {
|
|
public var search: String?
|
|
public var tags: [String]?
|
|
|
|
public init(search: String? = nil, tags: [String]? = nil) {
|
|
self.search = search
|
|
self.tags = tags
|
|
}
|
|
}
|
|
|
|
public protocol InstanceRepository: Sendable {
|
|
func list(_ query: InstanceQuery) async throws -> [Instance]
|
|
func get(_ id: UUID) async throws -> Instance?
|
|
func create(_ input: InstanceInput) async throws -> Instance
|
|
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance
|
|
func delete(_ id: UUID, expectedRevision: Int) async throws
|
|
}
|