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 }