import Foundation public struct InstanceGroup: Codable, Equatable, Identifiable, Sendable { public var id: UUID public var name: String public var createdAt: Date public init(id: UUID, name: String, createdAt: Date) { self.id = id self.name = name self.createdAt = createdAt } } public struct InstanceGroupInput: Sendable { public var name: String public init(name: String) { self.name = name } } public protocol InstanceGroupStore: Sendable { func list() async throws -> [InstanceGroup] func create(_ input: InstanceGroupInput) async throws -> InstanceGroup func update(_ id: UUID, _ name: String) async throws -> InstanceGroup func delete(_ id: UUID) async throws } public final class FileInstanceGroupStore: @unchecked Sendable, InstanceGroupStore { private struct FileModel: Codable { var groups: [InstanceGroup] = [] } private let location: URL private let encoder: JSONEncoder private let decoder: JSONDecoder private let lock = NSLock() private var fileModel = FileModel() private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-group-store") public init(location: URL) { self.location = URL(fileURLWithPath: location.path) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 self.decoder = decoder let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] self.encoder = encoder lock.withLock { if let data = try? Data(contentsOf: location), let decoded = try? decoder.decode(FileModel.self, from: data) { fileModel = decoded } } } public func list() async throws -> [InstanceGroup] { lock.withLock { fileModel.groups.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } } } public func create(_ input: InstanceGroupInput) async throws -> InstanceGroup { try await run { model in let name = try Self.normalized(input.name) guard !model.groups.contains(where: { $0.name == name }) else { throw RepositoryError.duplicateOrigin } let group = InstanceGroup(id: UUID(), name: name, createdAt: Date()) model.groups.append(group) return group } } public func update(_ id: UUID, _ name: String) async throws -> InstanceGroup { try await run { model in let normalized = try Self.normalized(name) guard let index = model.groups.firstIndex(where: { $0.id == id }) else { throw RepositoryError.notFound } guard !model.groups.contains(where: { $0.id != id && $0.name == normalized }) else { throw RepositoryError.duplicateOrigin } model.groups[index].name = normalized return model.groups[index] } } public func delete(_ id: UUID) async throws { try await run { model in model.groups.removeAll { $0.id == id } } } private func run( _ transform: @escaping @Sendable (inout FileModel) throws -> T ) async throws -> T { try await withCheckedThrowingContinuation { continuation in queue.async { do { var model = self.lock.withLock { self.fileModel } let output = try transform(&model) try self.write(model) self.lock.withLock { self.fileModel = model } continuation.resume(returning: output) } catch { continuation.resume(throwing: error) } } } } private func write(_ model: FileModel) throws { let directory = location.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try encoder.encode(model).write(to: location, options: [.atomic]) } private static func normalized(_ value: String) throws -> String { let name = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty, name.count <= 100 else { throw RepositoryError.invalidOrigin } return name } }