Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / Dao / SyncedSetting.swift

Back to remindwall — Current RemindWall

Download this file

// Observation plumbing mirrors sqlite-data's internal FetchKey:
// https://github.com/pointfreeco/sqlite-data/blob/main/Sources/SQLiteData/Internal/FetchKey.swift
@preconcurrency import Combine
import AppTypes
import CryptoKit
import Dependencies
import Foundation
import GRDB
import Sharing
import SQLiteData
import Synchronization
import Tagged

/// In-flight local save counts per setting key. While a local save is
/// pending, observation emissions are suppressed: the in-memory value is
/// newer than whatever the database says, and a stale echo of save N must
/// never clobber write N+1. The final commit's emission (or the equality of
/// in-memory with what was written) always reconciles.
private let pendingSaves = Mutex<[String: Int]>([:])

/// String round-trip for values stored through `.syncedSetting`.
///
/// The string is the same one `.appStorage` persists for
/// `RawRepresentable<String>` types, so a one-time seed from UserDefaults can
/// copy values verbatim.
public protocol SyncedSettingValue: Sendable {
    var settingValue: String { get }
    init?(settingValue: String)
}

extension String: SyncedSettingValue {
    public var settingValue: String { self }
    public init?(settingValue: String) { self = settingValue }
}

extension Bool: SyncedSettingValue {
    public var settingValue: String { self ? "true" : "false" }
    public init?(settingValue: String) {
        switch settingValue {
        case "true": self = true
        case "false": self = false
        default: return nil
        }
    }
}

extension Int: SyncedSettingValue {
    public var settingValue: String { String(self) }
    public init?(settingValue: String) {
        guard let value = Int(settingValue) else { return nil }
        self = value
    }
}

extension Tagged: SyncedSettingValue where RawValue == String {
    public var settingValue: String { rawValue }
    public init?(settingValue: String) { self.init(rawValue: settingValue) }
}

extension SharedKey {
    /// A setting replicated across devices through the CloudKit-synced
    /// `settings` table. A missing or undecodable row falls back to the
    /// `@Shared` default.
    public static func syncedSetting<V>(_ key: String) -> Self
    where Self == SyncedSettingKey<V>, V: SyncedSettingValue {
        SyncedSettingKey(
            key: key,
            encode: { $0.settingValue },
            decode: { raw in raw.flatMap(V.init(settingValue:)) }
        )
    }

    /// Optional flavor: writing nil deletes the row; a missing row falls back
    /// to the `@Shared` default (nil unless one is given).
    public static func syncedSetting<V>(_ key: String) -> Self
    where Self == SyncedSettingKey<V?>, V: SyncedSettingValue {
        SyncedSettingKey(
            key: key,
            encode: { $0?.settingValue },
            decode: { raw -> V?? in
                guard let raw, let value = V(settingValue: raw) else { return .none }
                return .some(.some(value))
            }
        )
    }
}

/// A `SharedKey` persisting one value in the `settings` table.
///
/// Reads resolve by key with the newest `lastModified` winning. Writes upsert a
/// row whose id derives deterministically from the key (UUIDv5), so every
/// device targets the same primary key and the sync engine merges concurrent
/// writes into one record instead of accumulating per-device duplicates. A
/// GRDB ValueObservation feeds SyncEngine-applied remote writes back into live
/// UI.
public struct SyncedSettingKey<Value: Sendable>: SharedKey {
    private let key: String
    private let database: any DatabaseWriter
    private let encode: @Sendable (Value) -> String?
    private let decode: @Sendable (String?) -> Value?

    fileprivate init(
        key: String,
        encode: @escaping @Sendable (Value) -> String?,
        decode: @escaping @Sendable (String?) -> Value?
    ) {
        @Dependency(\.defaultDatabase) var defaultDatabase
        self.key = key
        self.database = defaultDatabase
        self.encode = encode
        self.decode = decode
    }

    public struct ID: Hashable {
        fileprivate let key: String
        fileprivate let databaseID: ObjectIdentifier
        fileprivate let valueType: ObjectIdentifier
    }

    public var id: ID {
        ID(
            key: key,
            databaseID: ObjectIdentifier(database),
            valueType: ObjectIdentifier(Value.self)
        )
    }

    public func load(context: LoadContext<Value>, continuation: LoadContinuation<Value>) {
        guard case .userInitiated = context else {
            // At `@Shared` init the subscription delivers the first row
            // synchronously; a read here would be a duplicate.
            continuation.resumeReturningInitialValue()
            return
        }
        let key = key
        let decode = decode
        database.asyncRead { dbResult in
            let result = dbResult.flatMap { db in
                Result { try Self.currentRawValue(db, key: key) }
            }
            switch result {
            case .success(let raw):
                if let value = decode(raw) {
                    continuation.resume(returning: value)
                } else {
                    continuation.resumeReturningInitialValue()
                }
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }

    public func subscribe(
        context: LoadContext<Value>, subscriber: SharedSubscriber<Value>
    ) -> SharedSubscription {
        let key = key
        let decode = decode
        let observation = ValueObservation.tracking { db in
            try Self.currentRawValue(db, key: key)
        }
        let dropFirst =
            switch context {
            case .initialValue: false
            case .userInitiated: true
            }
        let cancellable = observation
            .publisher(in: database, scheduling: ImmediateScheduler())
            .dropFirst(dropFirst ? 1 : 0)
            .sink { completion in
                if case .failure(let error) = completion {
                    subscriber.yield(throwing: error)
                }
            } receiveValue: { raw in
                guard pendingSaves.withLock({ $0[key, default: 0] }) == 0 else { return }
                if let value = decode(raw) {
                    subscriber.yield(value)
                } else {
                    subscriber.yieldReturningInitialValue()
                }
            }
        return SharedSubscription {
            cancellable.cancel()
        }
    }

    public func save(_ value: Value, context: SaveContext, continuation: SaveContinuation) {
        // Resolved here, not at key init: only writers need a date, and
        // `lastModified` should come from the saving context's clock.
        @Dependency(\.date) var date
        let key = key
        let rowID = Setting.ID(.v5(name: key))
        let row = encode(value).map { encoded in
            Setting(id: rowID, key: key, value: encoded, lastModified: date())
        }
        pendingSaves.withLock { $0[key, default: 0] += 1 }
        database.asyncWrite { db in
            if let row {
                try Setting.upsert { row }.execute(db)
                // Concurrent first-writes on two devices can still leave a
                // stray row under another id; deleting it here makes the
                // table self-healing.
                try Setting
                    .where { $0.key.eq(key) && $0.id.neq(rowID) }
                    .delete()
                    .execute(db)
            } else {
                try Setting.where { $0.key.eq(key) }.delete().execute(db)
            }
        } completion: { _, result in
            pendingSaves.withLock { $0[key] = max(0, ($0[key] ?? 1) - 1) }
            switch result {
            case .success:
                continuation.resume()
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }

    /// Newest row wins when duplicates exist (last-writer-wins on `lastModified`).
    private static func currentRawValue(_ db: Database, key: String) throws -> String? {
        try Setting
            .where { $0.key.eq(key) }
            .order { $0.lastModified.desc() }
            .fetchOne(db)?
            .value
    }
}

/// The portable settings replicated across devices; albumId/calendarId stay
/// local (their identifiers don't travel between photo/calendar stores).
public let SYNCED_SETTING_SEED_KEYS = [
    SCREEN_OFF_SETTING_KEY,
    BUS_WINDOW_SETTING_KEY,
    BUS_ALERTS_ENABLED_SETTING_KEY,
]

/// One-time migration of appStorage-held settings into the synced settings
/// table. Seeds only keys with no existing row: an already-synced value beats
/// the local legacy one, and a seeded row never re-seeds.
public func seedSyncedSettings(
    from defaults: UserDefaults,
    keys: [String] = SYNCED_SETTING_SEED_KEYS,
    now: Date,
    in db: Database
) throws {
    for key in keys {
        guard
            try Setting.where({ $0.key.eq(key) }).fetchOne(db) == nil,
            let object = defaults.object(forKey: key)
        else { continue }
        let encoded: String
        switch object {
        case let string as String:
            encoded = string
        case let number as NSNumber:
            // appStorage stores Bool as a CFBoolean (objCType "c"); other
            // numbers keep their digits.
            encoded = String(cString: number.objCType) == "c"
                ? (number.boolValue ? "true" : "false")
                : number.stringValue
        default:
            continue
        }
        try Setting.insert {
            Setting(
                id: Setting.ID(.v5(name: key)),
                key: key,
                value: encoded,
                lastModified: now
            )
        }.execute(db)
    }
}

/// One-shot inheritance of the legacy per-mode bus settings by the per-watch
/// columns (TR1.7). Runs AFTER seedSyncedSettings so the settings table is the
/// single source (the appStorage values have already been copied in). The
/// caller guards with a LOCAL flag - re-running would clobber later per-watch
/// edits.
public func seedPerWatchSettings(in db: Database) throws {
    let legacyWindow = try Setting
        .where({ $0.key.eq(BUS_WINDOW_SETTING_KEY) })
        .order { $0.lastModified.desc() }
        .fetchOne(db)?
        .value
    if let legacyWindow {
        try db.execute(
            sql: """
            UPDATE "monitoredStops" SET "window" = ? WHERE "window" IS NULL
            """,
            arguments: [legacyWindow]
        )
    }
    let legacyEnabled = try Setting
        .where({ $0.key.eq(BUS_ALERTS_ENABLED_SETTING_KEY) })
        .order { $0.lastModified.desc() }
        .fetchOne(db)?
        .value
    if legacyEnabled == "false" {
        try db.execute(sql: """
            UPDATE "monitoredStops" SET "enabled" = 0
            """)
    }
}

extension UUID {
    /// RFC 4122 v5 (SHA-1, name-based) UUID: the same setting key maps to the
    /// same row id on every device.
    fileprivate static func v5(name: String) -> UUID {
        // Fixed namespace, generated once for the settings table.
        let namespace: uuid_t = (
            0xC4, 0x0D, 0x2F, 0xD1, 0x6B, 0xF0, 0x4E, 0x27,
            0x9C, 0x1B, 0x5A, 0x83, 0xE3, 0xD5, 0xD9, 0xA6
        )
        var data = Data()
        withUnsafeBytes(of: namespace) { data.append(contentsOf: $0) }
        data.append(contentsOf: Array(name.utf8))
        var digest = Array(Insecure.SHA1.hash(data: data))
        digest[6] = (digest[6] & 0x0F) | 0x50
        digest[8] = (digest[8] & 0x3F) | 0x80
        return UUID(uuid: (
            digest[0], digest[1], digest[2], digest[3],
            digest[4], digest[5], digest[6], digest[7],
            digest[8], digest[9], digest[10], digest[11],
            digest[12], digest[13], digest[14], digest[15]
        ))
    }
}

private struct ImmediateScheduler: ValueObservationScheduler, Hashable {
    func immediateInitialValue() -> Bool { true }
    func schedule(_ action: @escaping @Sendable () -> Void) {
        action()
    }
}