Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e5bf9550a4
commit
5ed222e423
@ -1,15 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Conditional migration for app version-based migration.
|
/// Conditional migration for app version-based migration.
|
||||||
public struct AppVersionConditionalMigration<Destination: StorageKey>: ConditionalMigration {
|
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration {
|
||||||
public typealias DestinationKey = Destination
|
public let destinationKey: StorageKey<Value>
|
||||||
|
|
||||||
public let destinationKey: Destination
|
|
||||||
public let minAppVersion: String
|
public let minAppVersion: String
|
||||||
public let fallbackMigration: AnyStorageMigration
|
public let fallbackMigration: AnyStorageMigration
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
destinationKey: Destination,
|
destinationKey: StorageKey<Value>,
|
||||||
minAppVersion: String,
|
minAppVersion: String,
|
||||||
fallbackMigration: AnyStorageMigration
|
fallbackMigration: AnyStorageMigration
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,23 +1,21 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct DefaultAggregatingMigration<Destination: StorageKey>: AggregatingMigration {
|
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration {
|
||||||
public typealias DestinationKey = Destination
|
public let destinationKey: StorageKey<Value>
|
||||||
|
|
||||||
public let destinationKey: Destination
|
|
||||||
public let sourceKeys: [AnyStorageKey]
|
public let sourceKeys: [AnyStorageKey]
|
||||||
public let aggregateAction: ([AnyCodable]) async throws -> Destination.Value
|
public let aggregateAction: ([AnyCodable]) async throws -> Value
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
destinationKey: Destination,
|
destinationKey: StorageKey<Value>,
|
||||||
sourceKeys: [AnyStorageKey],
|
sourceKeys: [AnyStorageKey],
|
||||||
aggregate: @escaping ([AnyCodable]) async throws -> Destination.Value
|
aggregate: @escaping ([AnyCodable]) async throws -> Value
|
||||||
) {
|
) {
|
||||||
self.destinationKey = destinationKey
|
self.destinationKey = destinationKey
|
||||||
self.sourceKeys = sourceKeys
|
self.sourceKeys = sourceKeys
|
||||||
self.aggregateAction = aggregate
|
self.aggregateAction = aggregate
|
||||||
}
|
}
|
||||||
|
|
||||||
public func aggregate(_ sources: [AnyCodable]) async throws -> Destination.Value {
|
public func aggregate(_ sources: [AnyCodable]) async throws -> Value {
|
||||||
try await aggregateAction(sources)
|
try await aggregateAction(sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +1,21 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct DefaultTransformingMigration<Source: StorageKey, Destination: StorageKey>: TransformingMigration {
|
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
|
||||||
public typealias SourceKey = Source
|
public let destinationKey: StorageKey<DestinationValue>
|
||||||
public typealias DestinationKey = Destination
|
public let sourceKey: StorageKey<SourceValue>
|
||||||
|
public let transformAction: (SourceValue) async throws -> DestinationValue
|
||||||
public let destinationKey: Destination
|
|
||||||
public let sourceKey: Source
|
|
||||||
public let transformAction: (Source.Value) async throws -> Destination.Value
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
destinationKey: Destination,
|
destinationKey: StorageKey<DestinationValue>,
|
||||||
sourceKey: Source,
|
sourceKey: StorageKey<SourceValue>,
|
||||||
transform: @escaping (Source.Value) async throws -> Destination.Value
|
transform: @escaping (SourceValue) async throws -> DestinationValue
|
||||||
) {
|
) {
|
||||||
self.destinationKey = destinationKey
|
self.destinationKey = destinationKey
|
||||||
self.sourceKey = sourceKey
|
self.sourceKey = sourceKey
|
||||||
self.transformAction = transform
|
self.transformAction = transform
|
||||||
}
|
}
|
||||||
|
|
||||||
public func transform(_ source: Source.Value) async throws -> Destination.Value {
|
public func transform(_ source: SourceValue) async throws -> DestinationValue {
|
||||||
try await transformAction(source)
|
try await transformAction(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Simple 1:1 legacy migration.
|
/// Simple 1:1 legacy migration.
|
||||||
public struct SimpleLegacyMigration<Destination: StorageKey>: StorageMigration {
|
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
|
||||||
public typealias DestinationKey = Destination
|
public let destinationKey: StorageKey<Value>
|
||||||
|
|
||||||
public let destinationKey: Destination
|
|
||||||
public let sourceKey: AnyStorageKey
|
public let sourceKey: AnyStorageKey
|
||||||
|
|
||||||
public init(destinationKey: Destination, sourceKey: AnyStorageKey) {
|
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) {
|
||||||
self.destinationKey = destinationKey
|
self.destinationKey = destinationKey
|
||||||
self.sourceKey = sourceKey
|
self.sourceKey = sourceKey
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ public struct AnyStorageKey: Sendable {
|
|||||||
public internal(set) var migration: AnyStorageMigration?
|
public internal(set) var migration: AnyStorageMigration?
|
||||||
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
|
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
|
||||||
|
|
||||||
public init<Key: StorageKey>(_ key: Key) {
|
public init<Value>(_ key: StorageKey<Value>) {
|
||||||
self.descriptor = .from(key)
|
self.descriptor = .from(key)
|
||||||
self.migration = key.migration
|
self.migration = key.migration
|
||||||
self.migrateAction = { router in
|
self.migrateAction = { router in
|
||||||
@ -21,7 +21,7 @@ public struct AnyStorageKey: Sendable {
|
|||||||
self.migrateAction = migrateAction
|
self.migrateAction = migrateAction
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey {
|
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
|
||||||
AnyStorageKey(key)
|
AnyStorageKey(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
Sources/LocalData/Models/StorageKey.swift
Normal file
40
Sources/LocalData/Models/StorageKey.swift
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
|
||||||
|
public let name: String
|
||||||
|
public let domain: StorageDomain
|
||||||
|
public let security: SecurityPolicy
|
||||||
|
public let serializer: Serializer<Value>
|
||||||
|
public let owner: String
|
||||||
|
public let description: String
|
||||||
|
public let availability: PlatformAvailability
|
||||||
|
public let syncPolicy: SyncPolicy
|
||||||
|
|
||||||
|
private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
domain: StorageDomain,
|
||||||
|
security: SecurityPolicy = .recommended,
|
||||||
|
serializer: Serializer<Value> = .json,
|
||||||
|
owner: String,
|
||||||
|
description: String,
|
||||||
|
availability: PlatformAvailability = .all,
|
||||||
|
syncPolicy: SyncPolicy = .never,
|
||||||
|
migration: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.domain = domain
|
||||||
|
self.security = security
|
||||||
|
self.serializer = serializer
|
||||||
|
self.owner = owner
|
||||||
|
self.description = description
|
||||||
|
self.availability = availability
|
||||||
|
self.syncPolicy = syncPolicy
|
||||||
|
self.migrationBuilder = migration
|
||||||
|
}
|
||||||
|
|
||||||
|
public var migration: AnyStorageMigration? {
|
||||||
|
migrationBuilder?(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -36,8 +36,8 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
|||||||
self.catalog = catalog
|
self.catalog = catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func from<Key: StorageKey>(
|
public static func from<Value>(
|
||||||
_ key: Key,
|
_ key: StorageKey<Value>,
|
||||||
catalog: String? = nil
|
catalog: String? = nil
|
||||||
) -> StorageKeyDescriptor {
|
) -> StorageKeyDescriptor {
|
||||||
StorageKeyDescriptor(
|
StorageKeyDescriptor(
|
||||||
@ -45,7 +45,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
|||||||
domain: key.domain,
|
domain: key.domain,
|
||||||
security: key.security,
|
security: key.security,
|
||||||
serializer: key.serializer.name,
|
serializer: key.serializer.name,
|
||||||
valueType: String(describing: Key.Value.self),
|
valueType: String(describing: Value.self),
|
||||||
owner: key.owner,
|
owner: key.owner,
|
||||||
availability: key.availability,
|
availability: key.availability,
|
||||||
syncPolicy: key.syncPolicy,
|
syncPolicy: key.syncPolicy,
|
||||||
|
|||||||
@ -3,5 +3,5 @@ import Foundation
|
|||||||
/// Migration protocol that combines multiple sources into a single destination.
|
/// Migration protocol that combines multiple sources into a single destination.
|
||||||
public protocol AggregatingMigration: StorageMigration {
|
public protocol AggregatingMigration: StorageMigration {
|
||||||
var sourceKeys: [AnyStorageKey] { get }
|
var sourceKeys: [AnyStorageKey] { get }
|
||||||
func aggregate(_ sources: [AnyCodable]) async throws -> DestinationKey.Value
|
func aggregate(_ sources: [AnyCodable]) async throws -> Value
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
public extension StorageKey {
|
|
||||||
var security: SecurityPolicy { .recommended }
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public protocol StorageKey: Sendable, CustomStringConvertible {
|
|
||||||
associatedtype Value: Codable & Sendable
|
|
||||||
|
|
||||||
var name: String { get }
|
|
||||||
var domain: StorageDomain { get }
|
|
||||||
var security: SecurityPolicy { get }
|
|
||||||
var serializer: Serializer<Value> { get }
|
|
||||||
var owner: String { get }
|
|
||||||
|
|
||||||
var availability: PlatformAvailability { get }
|
|
||||||
var syncPolicy: SyncPolicy { get }
|
|
||||||
|
|
||||||
var migration: AnyStorageMigration? { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StorageKey {
|
|
||||||
public var migration: AnyStorageMigration? { nil }
|
|
||||||
}
|
|
||||||
@ -2,10 +2,10 @@ import Foundation
|
|||||||
|
|
||||||
/// Core migration protocol with high-level methods.
|
/// Core migration protocol with high-level methods.
|
||||||
public protocol StorageMigration: Sendable {
|
public protocol StorageMigration: Sendable {
|
||||||
associatedtype DestinationKey: StorageKey
|
associatedtype Value: Codable & Sendable
|
||||||
|
|
||||||
/// The destination storage key where migrated data will be stored.
|
/// The destination storage key where migrated data will be stored.
|
||||||
var destinationKey: DestinationKey { get }
|
var destinationKey: StorageKey<Value> { get }
|
||||||
|
|
||||||
/// Validate if migration should proceed (conditional logic).
|
/// Validate if migration should proceed (conditional logic).
|
||||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
|
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public protocol StorageProviding: Sendable {
|
public protocol StorageProviding: Sendable {
|
||||||
func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws
|
func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws
|
||||||
func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value
|
func get<Value>(_ key: StorageKey<Value>) async throws -> Value
|
||||||
func remove<Key: StorageKey>(_ key: Key) async throws
|
func remove<Value>(_ key: StorageKey<Value>) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import Foundation
|
|||||||
|
|
||||||
/// Migration protocol that supports data transformation during migration.
|
/// Migration protocol that supports data transformation during migration.
|
||||||
public protocol TransformingMigration: StorageMigration {
|
public protocol TransformingMigration: StorageMigration {
|
||||||
associatedtype SourceKey: StorageKey
|
associatedtype SourceValue: Codable & Sendable
|
||||||
|
|
||||||
var sourceKey: SourceKey { get }
|
var sourceKey: StorageKey<SourceValue> { get }
|
||||||
func transform(_ source: SourceKey.Value) async throws -> DestinationKey.Value
|
func transform(_ source: SourceValue) async throws -> Value
|
||||||
}
|
}
|
||||||
|
|||||||
@ -127,7 +127,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the last migration date for a specific key, if available.
|
/// Returns the last migration date for a specific key, if available.
|
||||||
public func migrationHistory<Key: StorageKey>(for key: Key) -> Date? {
|
public func migrationHistory<Value>(for key: StorageKey<Value>) -> Date? {
|
||||||
migrationHistory[key.name]
|
migrationHistory[key.name]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,7 +138,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// - value: The value to store.
|
/// - value: The value to store.
|
||||||
/// - key: The storage key defining where and how to store.
|
/// - key: The storage key defining where and how to store.
|
||||||
/// - Throws: Various errors depending on the storage domain and security policy.
|
/// - Throws: Various errors depending on the storage domain and security policy.
|
||||||
public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
|
public func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws {
|
||||||
Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]")
|
Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]")
|
||||||
try validateCatalogRegistration(for: key)
|
try validateCatalogRegistration(for: key)
|
||||||
try validatePlatformAvailability(for: key)
|
try validatePlatformAvailability(for: key)
|
||||||
@ -155,7 +155,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// - Parameter key: The storage key to retrieve.
|
/// - Parameter key: The storage key to retrieve.
|
||||||
/// - Returns: The stored value.
|
/// - Returns: The stored value.
|
||||||
/// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors.
|
/// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors.
|
||||||
public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
|
public func get<Value>(_ key: StorageKey<Value>) async throws -> Value {
|
||||||
Logger.debug(">>> [STORAGE] GET: \(key.name)")
|
Logger.debug(">>> [STORAGE] GET: \(key.name)")
|
||||||
try validateCatalogRegistration(for: key)
|
try validateCatalogRegistration(for: key)
|
||||||
try validatePlatformAvailability(for: key)
|
try validatePlatformAvailability(for: key)
|
||||||
@ -182,7 +182,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// - Parameter key: The storage key to migrate.
|
/// - Parameter key: The storage key to migrate.
|
||||||
/// - Returns: The migration result.
|
/// - Returns: The migration result.
|
||||||
/// - Throws: Migration or storage errors.
|
/// - Throws: Migration or storage errors.
|
||||||
public func forceMigration<Key: StorageKey>(for key: Key) async throws -> MigrationResult {
|
public func forceMigration<Value>(for key: StorageKey<Value>) async throws -> MigrationResult {
|
||||||
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
|
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
|
||||||
try validateCatalogRegistration(for: key)
|
try validateCatalogRegistration(for: key)
|
||||||
|
|
||||||
@ -207,7 +207,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// Removes the value for the given key.
|
/// Removes the value for the given key.
|
||||||
/// - Parameter key: The storage key to remove.
|
/// - Parameter key: The storage key to remove.
|
||||||
/// - Throws: Domain-specific errors if removal fails.
|
/// - Throws: Domain-specific errors if removal fails.
|
||||||
public func remove<Key: StorageKey>(_ key: Key) async throws {
|
public func remove<Value>(_ key: StorageKey<Value>) async throws {
|
||||||
try validateCatalogRegistration(for: key)
|
try validateCatalogRegistration(for: key)
|
||||||
try validatePlatformAvailability(for: key)
|
try validatePlatformAvailability(for: key)
|
||||||
try await delete(for: .from(key))
|
try await delete(for: .from(key))
|
||||||
@ -216,7 +216,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// Checks if a value exists for the given key.
|
/// Checks if a value exists for the given key.
|
||||||
/// - Parameter key: The storage key to check.
|
/// - Parameter key: The storage key to check.
|
||||||
/// - Returns: True if a value exists.
|
/// - Returns: True if a value exists.
|
||||||
public func exists<Key: StorageKey>(_ key: Key) async throws -> Bool {
|
public func exists<Value>(_ key: StorageKey<Value>) async throws -> Bool {
|
||||||
try validateCatalogRegistration(for: key)
|
try validateCatalogRegistration(for: key)
|
||||||
try validatePlatformAvailability(for: key)
|
try validatePlatformAvailability(for: key)
|
||||||
|
|
||||||
@ -265,7 +265,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Platform Validation
|
// MARK: - Platform Validation
|
||||||
|
|
||||||
nonisolated private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
|
nonisolated private func validatePlatformAvailability<Value>(for key: StorageKey<Value>) throws {
|
||||||
#if os(watchOS)
|
#if os(watchOS)
|
||||||
if key.availability == .phoneOnly {
|
if key.availability == .phoneOnly {
|
||||||
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
|
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
|
||||||
@ -277,7 +277,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
|
private func validateCatalogRegistration<Value>(for key: StorageKey<Value>) throws {
|
||||||
guard !registeredKeys.isEmpty else { return }
|
guard !registeredKeys.isEmpty else { return }
|
||||||
guard registeredKeys[key.name] != nil else {
|
guard registeredKeys[key.name] != nil else {
|
||||||
let errorMessage = "UNREGISTERED STORAGE KEY: '\(key.name)' (Owner: \(key.owner)) accessed but not found in any registered catalog. Did you forget to call registerCatalog?"
|
let errorMessage = "UNREGISTERED STORAGE KEY: '\(key.name)' (Owner: \(key.owner)) accessed but not found in any registered catalog. Did you forget to call registerCatalog?"
|
||||||
@ -331,7 +331,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Migration
|
// MARK: - Migration
|
||||||
|
|
||||||
private func attemptMigration<Key: StorageKey>(for key: Key) async throws -> Key.Value? {
|
private func attemptMigration<Value>(for key: StorageKey<Value>) async throws -> Value? {
|
||||||
guard let migration = resolveMigration(for: key) else { return nil }
|
guard let migration = resolveMigration(for: key) else { return nil }
|
||||||
|
|
||||||
let context = buildMigrationContext()
|
let context = buildMigrationContext()
|
||||||
@ -354,7 +354,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveMigration<Key: StorageKey>(for key: Key) -> AnyStorageMigration? {
|
private func resolveMigration<Value>(for key: StorageKey<Value>) -> AnyStorageMigration? {
|
||||||
key.migration
|
key.migration
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,8 +366,8 @@ public actor StorageRouter: StorageProviding {
|
|||||||
migrationHistory[descriptor.name] = Date()
|
migrationHistory[descriptor.name] = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal func shouldAllowMigration<Key: StorageKey>(
|
internal func shouldAllowMigration<Value>(
|
||||||
for key: Key,
|
for key: StorageKey<Value>,
|
||||||
context: MigrationContext
|
context: MigrationContext
|
||||||
) async throws -> Bool {
|
) async throws -> Bool {
|
||||||
guard key.availability.isAvailable(on: context.deviceInfo.platform) else {
|
guard key.availability.isAvailable(on: context.deviceInfo.platform) else {
|
||||||
@ -440,7 +440,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Storage Operations
|
// MARK: - Storage Operations
|
||||||
|
|
||||||
private func store(_ data: Data, for key: any StorageKey) async throws {
|
private func store<Value>(_ data: Data, for key: StorageKey<Value>) async throws {
|
||||||
try await store(data, for: .from(key))
|
try await store(data, for: .from(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -544,7 +544,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Sync
|
// MARK: - Sync
|
||||||
|
|
||||||
private func handleSync(_ key: any StorageKey, data: Data) async throws {
|
private func handleSync<Value>(_ key: StorageKey<Value>, data: Data) async throws {
|
||||||
try await sync.syncIfNeeded(
|
try await sync.syncIfNeeded(
|
||||||
data: data,
|
data: data,
|
||||||
keyName: key.name,
|
keyName: key.name,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user