diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 2dffdab6c..bbc5e7e0b 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 47DDE1F353D72DB6C899D6E0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EC0A077F6E7EE1EB0853134B /* Foundation.framework */; }; 4C7D70012FC8892D0091C7A4 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4C7D80012FC8892D0091C7A4 /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */; }; + 4CA1B0022FD00002000AA002 /* FlipcashStore in Frameworks */ = {isa = PBXBuildFile; productRef = 4CA1B0012FD00001000AA001 /* FlipcashStore */; }; + 4CA1B0032FD00003000AA003 /* FlipcashStore in Frameworks */ = {isa = PBXBuildFile; productRef = 4CA1B0012FD00001000AA001 /* FlipcashStore */; }; 08B8484332ED78035E9D399B /* Bugsnag in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADEF1D62DD627C0001B260A /* Bugsnag */; }; 4CB225762F77260D0075874E /* FirebaseInstallations in Frameworks */ = {isa = PBXBuildFile; productRef = 4CB225752F77260D0075874E /* FirebaseInstallations */; }; 4CB225782F7726500075874E /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 4CB225772F7726500075874E /* FirebaseMessaging */; }; @@ -157,6 +159,7 @@ buildActionMask = 2147483647; files = ( 4C7D80012FC8892D0091C7A4 /* FlipcashCore in Frameworks */, + 4CA1B0022FD00002000AA002 /* FlipcashStore in Frameworks */, 08B8484332ED78035E9D399B /* Bugsnag in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -176,6 +179,7 @@ 508AF9C6D67C4CD29DE10A16 /* TweetNacl in Frameworks */, 9AC0156C2DA576FA0030298E /* opencv2.framework in Frameworks */, 9ABDD1952D9D7B61006B6CDA /* FlipcashCore in Frameworks */, + 4CA1B0032FD00003000AA003 /* FlipcashStore in Frameworks */, 9ADEF1D92DD627C6001B260A /* Mixpanel in Frameworks */, 9AE5C0122FA10003004C0DE0 /* SharedCoreKit in Frameworks */, ); @@ -322,6 +326,7 @@ name = NotificationService; packageProductDependencies = ( 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */, + 4CA1B0012FD00001000AA001 /* FlipcashStore */, 9ADEF1D62DD627C0001B260A /* Bugsnag */, ); productName = NotificationService; @@ -371,6 +376,7 @@ name = Flipcash; packageProductDependencies = ( 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */, + 4CA1B0012FD00001000AA001 /* FlipcashStore */, 9AC011172DA4320F0030298E /* FlipcashUI */, 9AD1265D2DA98ADC0048141F /* SQLite */, 9ADEF1D62DD627C0001B260A /* Bugsnag */, @@ -1820,6 +1826,10 @@ isa = XCSwiftPackageProductDependency; productName = FlipcashCore; }; + 4CA1B0012FD00001000AA001 /* FlipcashStore */ = { + isa = XCSwiftPackageProductDependency; + productName = FlipcashStore; + }; 9AC011172DA4320F0030298E /* FlipcashUI */ = { isa = XCSwiftPackageProductDependency; productName = FlipcashUI; diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11431824b..4ed943956 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bfbaca6df065c0441a76cea8f5de36ddcc5deb1584f25cf135fc57d082acd454", + "originHash" : "4a4f991f35e10ddca66ba10d214729d965d97db3e9a2bc168bb6c28231895a53", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "27e3f09a82f6fbd41c03026ee738f943f4ed465e", - "version" : "0.4.0" + "revision" : "524cfbee86388ee0b13078d6159e4d2961fe130a", + "version" : "0.5.0" } }, { diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index 27e277060..f04a714f3 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -129,6 +129,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { sessionContainer?.session.didEnterBackground() container.preferences.appDidEnterBackground() sessionContainer?.pushController.clearBadgeCount() + closeDatabase() case .active: logger.info("scenePhase → active") container.client.warmUpChannel() @@ -147,6 +148,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } + /// Checkpoints and closes the store on the way to the background. + /// + /// `.active` has no counterpart on purpose: the connections reopen on the first + /// read after the app comes back, so a return that never happens costs nothing and + /// a close that lands at an awkward moment repairs itself. + /// + /// The background-task assertion covers the checkpoint, which is file I/O + /// proportional to the write-ahead log. Being suspended partway through it leaves + /// the log on disk for the next launch to replay rather than damaging the store, so + /// the assertion buys a faster next launch, not correctness. + private func closeDatabase() { + guard let database = sessionContainer?.database else { + return + } + + var identifier = UIBackgroundTaskIdentifier.invalid + identifier = UIApplication.shared.beginBackgroundTask(withName: "database.close") { + UIApplication.shared.endBackgroundTask(identifier) + identifier = .invalid + } + + do { + try database.close() + } catch { + logger.error("Failed to close the database", metadata: ["error": "\(error)"]) + } + + if identifier != .invalid { + UIApplication.shared.endBackgroundTask(identifier) + } + } + // MARK: - Deep Links - func handleOpenURL(url: URL) { diff --git a/Flipcash/Core/Controllers/BlocklistController.swift b/Flipcash/Core/Controllers/BlocklistController.swift index 44df812a2..67a36c109 100644 --- a/Flipcash/Core/Controllers/BlocklistController.swift +++ b/Flipcash/Core/Controllers/BlocklistController.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.blocklist-controller") diff --git a/Flipcash/Core/Controllers/ContactDirectory.swift b/Flipcash/Core/Controllers/ContactDirectory.swift index ea673b27a..114d553db 100644 --- a/Flipcash/Core/Controllers/ContactDirectory.swift +++ b/Flipcash/Core/Controllers/ContactDirectory.swift @@ -6,6 +6,7 @@ import Contacts import Foundation import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.contact-directory") diff --git a/Flipcash/Core/Controllers/ContactSyncController.swift b/Flipcash/Core/Controllers/ContactSyncController.swift index 2247d73ac..4607c68ae 100644 --- a/Flipcash/Core/Controllers/ContactSyncController.swift +++ b/Flipcash/Core/Controllers/ContactSyncController.swift @@ -6,6 +6,7 @@ import Contacts import Foundation import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.contact-sync-controller") @@ -50,7 +51,7 @@ final class ContactSyncController { /// Set once, during the user's first contact scan, to the number of the /// user's contacts the server matched. Gated on the durable `contactsConnected` /// flag (UserDefaults, not the DB) so it fires once per device and never - /// re-fires after a `SQLiteVersion` rebuild, later syncs, or screen opens. + /// re-fires after a `schemaVersion` rebuild, later syncs, or screen opens. var onFlipcashMatchCount: Int? nonisolated private var ownerKeyPair: KeyPair { @@ -275,7 +276,7 @@ final class ContactSyncController { await resolveDirectory() // Fire the one-time "already on Flipcash" dialog on the first connect. - // The flag lives in UserDefaults, not the DB, so a `SQLiteVersion` + // The flag lives in UserDefaults, not the DB, so a `schemaVersion` // rebuild never re-fires it for an existing user. await MainActor.run { guard UserDefaults.contactsConnected != true else { return } @@ -545,7 +546,7 @@ extension ContactSyncController: DMContactNaming { extension UserDefaults { /// `true` once this device has completed its first contact connect. Gates the /// one-time "already on Flipcash" dialog; persisted here rather than in the - /// per-account SQLite store so a `SQLiteVersion` rebuild doesn't re-fire it. + /// per-account SQLite store so a `schemaVersion` rebuild doesn't re-fire it. @Defaults(.contactsConnected) static var contactsConnected: Bool? } diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index ef5e9e434..682e36d94 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.conversation-controller") @@ -450,7 +451,7 @@ final class ConversationController { private func hydrateIfUnknown(_ event: ConversationStreamEvent) { let conversationID: ConversationID switch event { - case .newMessages(let id, _), .chatEvents(let id, _), .lastActivityChanged(let id, _), .readPointersChanged(let id, _): + case .chatEvents(let id, _), .lastActivityChanged(let id, _), .readPointersChanged(let id, _): conversationID = id case .metadataRefresh: return @@ -613,22 +614,9 @@ final class ConversationController { /// post-`apply` state so monotonic rules (read pointers) hold. private func persist(event: ConversationStreamEvent) { switch event { - case .newMessages(let conversationID, let messages): + case .chatEvents(let conversationID, let events): // Read before the write: the newest stored id is the analytics watermark, // and after the upsert it would already include this batch. - let countedThrough = (try? database.newestMessageID(conversationID: conversationID)) ?? nil - let (reconciled, pairs) = reconciledForPersist(messages, in: conversationID) - let ok = persist(operation: "upsert-messages") { try database.upsertConversationMessages(reconciled, conversationID: conversationID) } - if ok { - commitReconciled(pairs, in: conversationID) - receipts.countReceived(reconciled, countedThrough: countedThrough, delivery: .live) - } else { - // The delivered batch is in neither the DB nor the store — refetch it from the event log. - scheduleGapCatchUp(conversationID) - } - refreshFeedPreview(for: conversationID) - persistConversation(conversationID) - case .chatEvents(let conversationID, let events): let countedThrough = (try? database.newestMessageID(conversationID: conversationID)) ?? nil let (reconciled, pairs) = reconciledForPersist(events.flatMap { $0.mutations.map(\.message) }, in: conversationID) // Messages + the advanced cursor persist atomically. `store.apply` already advanced the diff --git a/Flipcash/Core/Controllers/Database/Database.swift b/Flipcash/Core/Controllers/Database/Database.swift deleted file mode 100644 index 2fcce265c..000000000 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// Database.swift -// Code -// -// Created by Dima Bart on 2025-04-11. -// - -import Foundation -import FlipcashCore -import SQLite - -nonisolated private let logger = Logger(label: "flipcash.database") - -typealias Expression = SQLite.Expression - -// SQLite.swift serializes reads/writes through each `Connection`'s own -// dispatch queue, so concurrent calls into `reader` and `writer` are safe -// despite Database itself being a reference type. Marking it -// `@unchecked Sendable` lets background write paths (e.g. RatesController's -// rate persistence queue) capture it without escaping Swift 6 isolation. -// FOLLOW-UP: Remove @unchecked when SQLite.swift declares Connection: Sendable. -nonisolated class Database: @unchecked Sendable { - - let reader: Connection - let writer: Connection - - private let storeURL: URL - - // MARK: - Init - - - init(url: URL) throws { - self.storeURL = url - - self.writer = try Connection(url.path) - - writer.busyTimeout = 2000 // 2 sec - try writer.run("PRAGMA journal_mode = WAL;") - try writer.run("PRAGMA cache_size = 10000;") - try writer.run("PRAGMA foreign_keys = ON;") - - self.reader = try Connection(url.path, readonly: true) - reader.busyTimeout = 2000 // 2 Sec - - try createTablesIfNeeded() - } - - // MARK: - Transaction - - - /// Always inline this function to ensure that captureError - /// captures the function in which this was called, otherwise - /// it will always captured in transaction {} - @inline(__always) - func transaction(silent: Bool = false, _ block: (Database) throws -> Void) rethrows { - do { - let startChangeCount = writer.totalChanges - try writer.transaction { [unowned self] in - try block(self) - } - let endChangeCount = writer.totalChanges - - // There are instances where we want to commit - // the transaction but avoid notifying the UI - // layer of the change. Also, we'll check if - // there's been any changes in this transaction - // to avoid reloading unnecessarily. - if !silent { - let changeDelta = endChangeCount - startChangeCount - if changeDelta > 0 { - NotificationQueue.default.enqueue( - .init( - name: .databaseDidChange, - userInfo: [ - "changeCount": changeDelta, - ] - ), - postingStyle: .asap, - coalesceMask: .onName, - forModes: [.common] - ) - } - } - - } catch { - logger.error("Transaction error", metadata: ["error": "\(error)"]) - } - } - - // MARK: - Lifecycle - - - /// Flushes the write-ahead log back into the main database file and truncates it. - /// - /// TRUNCATE rather than PASSIVE: a passive checkpoint gives up silently when any - /// reader is mid-transaction, which is the case that leaves the WAL growing without - /// bound. This blocks up to `busyTimeout` instead, and throws when it cannot finish. - func checkpoint() throws { - try writer.run("PRAGMA wal_checkpoint(TRUNCATE);") - } - - // MARK: - Versioning - - - static func deleteStore(owner: PublicKey) throws { - let urlsToRemove: [URL] = [ - .dataStore(owner: owner), - .storeSHM(owner: owner), - .storeWAL(owner: owner), - ] - - try urlsToRemove.forEach { - if FileManager.default.fileExists(atPath: $0.path) { - try FileManager.default.removeItem(at: $0) - } - } - } - - static func setUserVersion(version: Int, owner: PublicKey) throws { - try! "\(version)".write( - to: .versionFile(owner: owner), - atomically: true, - encoding: .utf8 - ) - } - - static func userVersion(owner: PublicKey) throws -> Int? { - let versionString = try String( - contentsOf: .versionFile(owner: owner), - encoding: .utf8 - ) - - return Int(versionString.trimmingCharacters(in: .whitespacesAndNewlines)) - } -} - -nonisolated extension URL { - static func dataStore(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite") - } - - static func storeWAL(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite-wal") - } - - static func storeSHM(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite-shm") - } - - static func versionFile(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58)version") - } -} - -nonisolated extension Notification.Name { - static let databaseDidChange = Notification.Name("databaseDidChange") -} - diff --git a/Flipcash/Core/Controllers/HistoryController.swift b/Flipcash/Core/Controllers/HistoryController.swift index 44003a766..41016d306 100644 --- a/Flipcash/Core/Controllers/HistoryController.swift +++ b/Flipcash/Core/Controllers/HistoryController.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.history-controller") diff --git a/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift b/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift index 52690752b..f806534dc 100644 --- a/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift +++ b/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift @@ -12,7 +12,7 @@ import FlipcashCore /// /// The email flow writes the fallback when `requireCoinbaseEmailVerification` /// is off; logout clears it. It lives in UserDefaults rather than SQLite -/// because the server never sees it — a `SQLiteVersion` rebuild (which +/// because the server never sees it — a `schemaVersion` rebuild (which /// restores only server data) would lose it. enum CoinbaseOrderEmail { diff --git a/Flipcash/Core/Controllers/RatesController.swift b/Flipcash/Core/Controllers/RatesController.swift index fe1a85f41..dd71ecfd2 100644 --- a/Flipcash/Core/Controllers/RatesController.swift +++ b/Flipcash/Core/Controllers/RatesController.swift @@ -14,6 +14,7 @@ import Foundation // or these are migrated to AsyncSequence. @preconcurrency import Combine import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.rates-controller") diff --git a/Flipcash/Core/Controllers/Database/Updateable.swift b/Flipcash/Core/Controllers/Updateable.swift similarity index 100% rename from Flipcash/Core/Controllers/Database/Updateable.swift rename to Flipcash/Core/Controllers/Updateable.swift diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift index 62cb88c57..420556543 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift @@ -5,6 +5,7 @@ import Foundation import FlipcashCore +import FlipcashStore /// Read access to every balance the launch gate weighs. @MainActor diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift index 4d90bb41b..8d81860a6 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.add-money-processing") diff --git a/Flipcash/Core/Screens/Main/Bill/BillValuation.swift b/Flipcash/Core/Screens/Main/Bill/BillValuation.swift index a0e13ca9a..1e00da359 100644 --- a/Flipcash/Core/Screens/Main/Bill/BillValuation.swift +++ b/Flipcash/Core/Screens/Main/Bill/BillValuation.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore struct BillValuation: Identifiable { diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 11711f357..7084c788e 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.buy-amount") diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift index cd501acdc..751eabdfa 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI struct BuyConfirmationScreen: View { diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift index 5f99c1e2a..593376298 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.buy-confirmation") diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift index d89747012..1d3544102 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore /// Sub-flow path for the buy stack. The `.buy(mint)` sheet's root is /// `BuyAmountScreen`; secondary screens (buy summary, post-buy processing) are diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift index e4319fc8d..65ac25012 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI /// Amount entry for converting a currency into a chosen destination. Pushed diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift index 582658861..1e2cb0623 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.convert-amount") diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index f05337781..434ac1bc3 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -6,6 +6,7 @@ import SwiftUI import UniformTypeIdentifiers import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.currency-creation") diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift index effbc0b82..ff5f6b1c6 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI @Observable diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index ce2e1fa6e..25cd995d2 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -10,6 +10,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI /// Marks the hero card as the morph destination for the wallet's tapped card. diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index c849ab61d..07c65b23e 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -8,6 +8,7 @@ import SwiftUI import FlipcashUI import FlipcashCore +import FlipcashStore /// Thin environment-reading wrapper that hands the DI containers to /// ``CurrencyInfoScreenContent``, whose two-init delegation builds the `@State` diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index e1ebd180e..3e757abe4 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore @Observable class CurrencyInfoViewModel { diff --git a/Flipcash/Core/Screens/Main/ExchangedBalance.swift b/Flipcash/Core/Screens/Main/ExchangedBalance.swift index 388208ed2..f4e75ad37 100644 --- a/Flipcash/Core/Screens/Main/ExchangedBalance.swift +++ b/Flipcash/Core/Screens/Main/ExchangedBalance.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore /// A stored balance paired with its fiat value at a given rate. struct ExchangedBalance: Identifiable, Hashable { diff --git a/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift b/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift index e84a85066..f479d994d 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift @@ -6,6 +6,7 @@ import SwiftUI import FlipcashUI import FlipcashCore +import FlipcashStore /// The avatar an activity draws — the counterparty's profile photo for peer /// activity (tips/sends), the token image for token activity (deposits, buys), diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index ffd45b6ef..cfaad557d 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -7,6 +7,7 @@ import SwiftUI import UIKit import FlipcashUI import FlipcashCore +import FlipcashStore /// One activity entry in full (Figma node 9708:105260) — what opens when a row is /// tapped in the Wallet's Recent section, the cross-token history, or a token's diff --git a/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift b/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift index f472873c5..d57f194a9 100644 --- a/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift +++ b/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.scan-cash") diff --git a/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift b/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift index be9b0b5bd..5919f0846 100644 --- a/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift +++ b/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.send-cash") diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index b4beaa2b3..211262f1d 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -8,6 +8,7 @@ import UIKit import FlipcashUI import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.session") diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 17114cb37..6710ac1d5 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.session-auth") @@ -293,30 +294,71 @@ final class SessionAuthenticator { // MARK: - Database - private func initializeDatabase(owner: PublicKey) throws -> Database { - try createApplicationSupportIfNeeded() - + // Resolved once. Two calls could in principle disagree — the container lookup is a + // system call, not a constant — and a migration that reads one directory while the + // store opens from another is the failure this avoids. + let location = StoreLocation.resolved() + let files = location.files(owner: owner) + + if !location.isShared { + // The store still works; the notification extension cannot see it, so anything the + // extension prefetches is invisible to the app until this is fixed. That is an + // entitlement or provisioning problem, and it is silent without this. + ErrorReporting.captureError( + StoreError.appGroupUnavailable, + reason: "App Group container unavailable, database fell back to Application Support" + ) + } + + try createStoreDirectoryIfNeeded(at: location.directory) + + switch StoreMigration.migrateIfNeeded(owner: owner, location: location) { + case .notNeeded, .alreadyMigrated: + break + case .migrated: + logger.info("Migrated the database into the App Group container.") + case .failed(let description): + // The migration cleaned up after itself, so what follows opens a fresh store and + // sync repopulates it. Worth reporting because the user pays for it in a full + // re-sync, and because it means the legacy store is still on disk. + ErrorReporting.captureError( + StoreError.migrationFailed(description), + reason: "Database migration to the App Group container failed" + ) + } + // Currently we don't do migrations so every time // the user version is outdated, we'll rebuild the // database during sync. - let userVersion = (try? Database.userVersion(owner: owner)) ?? 0 - let currentVersion = try InfoPlist.value(for: "SQLiteVersion").integer() + let userVersion = (try? Database.userVersion(files: files)) ?? 0 + let currentVersion = Database.schemaVersion if currentVersion > userVersion { - try Database.deleteStore(owner: owner) + try Database.deleteStore(files: files) logger.error("Outdated user version, deleted database.") - try Database.setUserVersion(version: currentVersion, owner: owner) + try Database.setUserVersion(version: currentVersion, files: files) } - return try Database(url: .dataStore(owner: owner)) + return try Database(url: files.database) } - private func createApplicationSupportIfNeeded() throws { - if !FileManager.default.fileExists(atPath: URL.applicationSupportDirectory.path) { + /// Creates the store's directory when it is missing. + /// + /// `withIntermediateDirectories: true` where the old Application Support version passed + /// `false`: the App Group container root already exists once it resolves, so the call is + /// usually a no-op, and `true` also makes it succeed rather than throw in that case. + private func createStoreDirectoryIfNeeded(at directory: URL) throws { + if !FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: false + at: directory, + withIntermediateDirectories: true ) } } + + private enum StoreError: Error { + case appGroupUnavailable + case migrationFailed(String) + } // MARK: - Login - diff --git a/Flipcash/Core/Session/SessionProtocols.swift b/Flipcash/Core/Session/SessionProtocols.swift index 01e453deb..5968d3a88 100644 --- a/Flipcash/Core/Session/SessionProtocols.swift +++ b/Flipcash/Core/Session/SessionProtocols.swift @@ -5,6 +5,7 @@ import Foundation import FlipcashCore +import FlipcashStore // MARK: - Account diff --git a/Flipcash/Supporting Files/Info.plist b/Flipcash/Supporting Files/Info.plist index e5d12f210..1bcdc4734 100644 --- a/Flipcash/Supporting Files/Info.plist +++ b/Flipcash/Supporting Files/Info.plist @@ -22,8 +22,6 @@ INSendMessageIntent com.flipcash.app.openChat - SQLiteVersion - 35 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 9babd2a7a..075cfa234 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -35,7 +35,7 @@ let contractDependencies: [Package.Dependency] = protoLocalRoot.map { root in ] } ?? [ .package(url: "https://github.com/code-payments/ocp-client-protocol", exact: "0.3.0"), - .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.4.0"), + .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.5.0"), ] let package = Package( diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index a983cc036..f7c3b9d49 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -14,6 +14,10 @@ let package = Package( name: "FlipcashCore", targets: ["FlipcashCore"] ), + .library( + name: "FlipcashStore", + targets: ["FlipcashStore"] + ), ], dependencies: [ .package(url: "https://github.com/marmelroy/PhoneNumberKit", from: "4.1.4"), @@ -24,6 +28,9 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), .package(path: "../FlipcashAPI"), .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.5.0")), + // Branch-pinned to match the app project's own reference to the same fork. SPM resolves one + // version of it for the whole graph, so the two have to agree. + .package(url: "https://github.com/dbart01/SQLite.swift", branch: "master"), ], targets: [ .target( @@ -43,6 +50,17 @@ let package = Package( .copy("Resources/discrete_cumulative_table.bin"), ] ), + // The SQLite store, shared by the app and the notification service extension. It is a + // separate target rather than part of `FlipcashCore` so that everything depending on the + // models does not also pull in SQLite. + .target( + name: "FlipcashStore", + dependencies: [ + "FlipcashCore", + .product(name: "Logging", package: "swift-log"), + .product(name: "SQLite", package: "SQLite.swift"), + ] + ), .testTarget( name: "FlipcashCoreTests", dependencies: [ diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 64b24cc10..c52d2918e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -288,11 +288,6 @@ public struct ConversationStore: Sendable { @discardableResult public mutating func apply(_ event: ConversationStreamEvent) -> GapSignal { switch event { - case .newMessages(let conversationID, let messages): - if let latest = messages.max(by: { $0.id < $1.id }) { - advanceLastActivity(to: latest.date, in: conversationID) - } - return .none case .chatEvents(let conversationID, let events): return applyChatEvents(events, into: conversationID) case .metadataRefresh(let conversation): diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index fbbc0b901..5dc749284 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -13,12 +13,9 @@ import FlipcashAPI /// apply them without touching proto types. public enum ConversationStreamEvent: Sendable { - /// New messages arrived in a conversation. - case newMessages(conversationID: ConversationID, messages: [ConversationMessage]) - /// Durable, sequenced event-log mutations for a conversation (message sent/edited/deleted). The /// store applies them last-writer-wins by `event_sequence` and gap-detects via `sequence`/`count`, - /// catching up with `GetDelta` on a gap. Supersedes the deprecated `newMessages` overlay. + /// catching up with `GetDelta` on a gap. case chatEvents(conversationID: ConversationID, events: [DecodedChatEvent]) /// A conversation's full metadata was refreshed (members/last message/last activity). @@ -93,21 +90,12 @@ extension ConversationStreamEvent { let conversationID = ConversationID(update.chat) var events: [ConversationStreamEvent] = [] - // The sequenced event log (message sent/edited/deleted). Additive with `new_messages`: both may - // carry the same send during the server's migration window, and last-writer-wins by - // `event_sequence` in the store lands it exactly once. + // The sequenced event log (message sent/edited/deleted). let chatEvents = update.events.events.map(DecodedChatEvent.init) if !chatEvents.isEmpty { events.append(.chatEvents(conversationID: conversationID, events: chatEvents)) } - // The deprecated real-time overlay. Decoded regardless of `events` so a message that arrives - // only here (an events-empty or malformed batch) is never dropped; the store dedups by version. - let messages = update.newMessages.messages.compactMap(ConversationMessage.init) - if !messages.isEmpty { - events.append(.newMessages(conversationID: conversationID, messages: messages)) - } - for metadataUpdate in update.metadataUpdates { switch metadataUpdate.kind { case .fullRefresh(let refresh): diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 88b52d222..aaf97793e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -45,4 +45,19 @@ public enum NotificationPayload { } return ConversationType(payload.chatMetadata.type) } + + /// The message a CHAT push carries inline, or `nil` when the push isn't a chat message, carries + /// no chat metadata, predates the server embedding the message, or carries content this client + /// can't represent. + /// + /// The embedded message is the only part of a push that needs no network to become store rows. + /// It carries the same `eventSequence` the transcript fetch would return for it, so it merges + /// with a fetched message rather than competing with one. + public static func chatMessage(_ userInfo: [AnyHashable: Any]) -> ConversationMessage? { + guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { + return nil + } + guard payload.chatMetadata.hasMessage else { return nil } + return ConversationMessage(payload.chatMetadata.message) + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift b/FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift new file mode 100644 index 000000000..754eee786 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift @@ -0,0 +1,122 @@ +// +// StoreLocation.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Where the SQLite store lives, and where it used to live. +/// +/// The store started in the app's private Application Support directory, which the notification +/// extensions cannot open — separate processes, separate containers. Moving it into the App Group +/// container is what lets the extension write messages that the app will later read. +/// +/// This type is paths only. It resolves the container and names the files; it never opens or moves +/// anything. That keeps it testable against temporary directories, and keeps `FlipcashCore` free of +/// a SQLite dependency it does not otherwise have. +public struct StoreLocation: Sendable { + + /// The App Group shared by the app and both notification extensions. + public static let appGroup = NotificationPreviewCache.appGroup + + /// Where the store lives now. + public let directory: URL + + /// Where the store lived before the App Group move, and where a pre-move install still has it. + public let legacyDirectory: URL + + /// False when the App Group container could not be resolved and `directory` fell back to + /// `legacyDirectory`. + /// + /// That happens when the entitlement is missing or the group is not provisioned for the running + /// build — a build configuration problem rather than a runtime condition to recover from. + /// Falling back keeps the app working, with an extension that cannot see the store, instead of + /// refusing to launch. The flag is here so the caller can report it: `FlipcashCore` has no + /// reporting channel of its own. + public let isShared: Bool + + public init(directory: URL, legacyDirectory: URL, isShared: Bool) { + self.directory = directory + self.legacyDirectory = legacyDirectory + self.isShared = isShared + } + + /// Resolves the App Group container, falling back to the legacy directory when it is missing. + /// + /// `containerURL` is injected rather than called directly because the lookup it wraps is not + /// consistent across platforms: on iOS `containerURL(forSecurityApplicationGroupIdentifier:)` + /// returns nil when the app lacks the entitlement, but on macOS — where this package's tests + /// run — it returns a constructed path for any identifier, provisioned or not. The nil branch is + /// therefore unreachable from a test that passes a bogus group name, and the seam is what makes + /// the fallback testable at all. + public static func resolved( + appGroup: String = StoreLocation.appGroup, + legacyDirectory: URL = .applicationSupportDirectory, + containerURL: (String) -> URL? = { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0) + } + ) -> StoreLocation { + guard let container = containerURL(appGroup) else { + return StoreLocation( + directory: legacyDirectory, + legacyDirectory: legacyDirectory, + isShared: false + ) + } + + // The store goes in the container root rather than a subdirectory. The root is guaranteed to + // exist once the container resolves, which removes a create-directory step — and its failure + // mode — from the migration path. The file names are already owner-scoped and prefixed, so + // they cannot collide with `ChatPreviews/`. + return StoreLocation( + directory: container, + legacyDirectory: legacyDirectory, + isShared: true + ) + } + + // MARK: - Files - + + /// The four files that make up one owner's store within a single directory. + /// + /// SQLite derives the `-wal` and `-shm` names from the main file rather than being told them, so + /// these are not independently choosable paths. They are named here because the migration and + /// `Database.deleteStore` both have to account for them. + public struct Files: Sendable, Equatable { + public let database: URL + public let wal: URL + public let shm: URL + public let version: URL + + public init(database: URL, wal: URL, shm: URL, version: URL) { + self.database = database + self.wal = wal + self.shm = shm + self.version = version + } + } + + /// The owner's store files in the current directory. + public func files(owner: PublicKey) -> Files { + Self.files(owner: owner, in: directory) + } + + /// The owner's store files in the pre-move directory. + public func legacyFiles(owner: PublicKey) -> Files { + Self.files(owner: owner, in: legacyDirectory) + } + + private static func files(owner: PublicKey, in directory: URL) -> Files { + let base = "flipcash-\(owner.base58)" + return Files( + database: directory.appendingPathComponent("\(base).sqlite"), + wal: directory.appendingPathComponent("\(base).sqlite-wal"), + shm: directory.appendingPathComponent("\(base).sqlite-shm"), + // No separator before "version", and no extension. This is the name a pre-move install + // already has on disk, so the migration has to look for exactly this to find it. + version: directory.appendingPathComponent("\(base)version") + ) + } +} diff --git a/Flipcash/Core/Controllers/Database/Database+Activities.swift b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift similarity index 96% rename from Flipcash/Core/Controllers/Database/Database+Activities.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Activities.swift index 179ecaca9..625962d0d 100644 --- a/Flipcash/Core/Controllers/Database/Database+Activities.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift @@ -13,7 +13,7 @@ nonisolated extension Database { // MARK: - Get - - func getLatestActivityID() throws -> PublicKey? { + public func getLatestActivityID() throws -> PublicKey? { let statement = try reader.prepareRowIterator(""" SELECT a.id @@ -32,7 +32,7 @@ nonisolated extension Database { return ids.first } - func getPendingActivityIDs() throws -> [PublicKey] { + public func getPendingActivityIDs() throws -> [PublicKey] { let statement = try reader.prepareRowIterator(""" SELECT a.id @@ -55,7 +55,7 @@ nonisolated extension Database { /// serialises access through its own dispatch queue; this wrapper only /// hops off main so the caller's actor isn't blocked on up-to-1024 /// `NSDateFormatter.dateFromString(_:)` calls. - func getActivities(mint: PublicKey) async throws -> [Activity] { + public func getActivities(mint: PublicKey) async throws -> [Activity] { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { @@ -68,7 +68,7 @@ nonisolated extension Database { } } - func getActivities(mint: PublicKey) throws -> [Activity] { + public func getActivities(mint: PublicKey) throws -> [Activity] { let statement = try reader.prepareRowIterator(""" SELECT a.id, @@ -115,7 +115,7 @@ nonisolated extension Database { /// The unified, cross-mint recent activity for the wallet preview: the newest /// `limit` activities regardless of token. `getActivities(mint:)` is the /// per-token slice; this is the "everything" feed. - func getRecentActivities(limit: Int) throws -> [Activity] { + public func getRecentActivities(limit: Int) throws -> [Activity] { let statement = try reader.prepareRowIterator(""" SELECT a.id, @@ -250,7 +250,7 @@ nonisolated extension Database { // MARK: - Insert - - func insertActivities(activities: [Activity]) throws { + public func insertActivities(activities: [Activity]) throws { try activities.forEach { try insertActivity(activity: $0) } diff --git a/Flipcash/Core/Controllers/Database/Database+Balance.swift b/FlipcashCore/Sources/FlipcashStore/Database+Balance.swift similarity index 96% rename from Flipcash/Core/Controllers/Database/Database+Balance.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Balance.swift index 1a262f97a..c0316cf3c 100644 --- a/Flipcash/Core/Controllers/Database/Database+Balance.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Balance.swift @@ -15,7 +15,7 @@ nonisolated extension Database { // MARK: - Get - - func getBalances() throws -> [StoredBalance] { + public func getBalances() throws -> [StoredBalance] { let statement = try reader.prepareRowIterator(""" SELECT b.quarks, @@ -58,7 +58,7 @@ nonisolated extension Database { return balances } - func getMintMetadata(mint: PublicKey) throws -> StoredMintMetadata? { + public func getMintMetadata(mint: PublicKey) throws -> StoredMintMetadata? { let stored = try fetchStoredMint(mint) if stored == nil { logger.warning("Missing mint in database", metadata: ["mint": "\(mint.base58)"]) @@ -132,7 +132,7 @@ nonisolated extension Database { return mints.first } - func getVMAuthority(mint: PublicKey) throws -> PublicKey? { + public func getVMAuthority(mint: PublicKey) throws -> PublicKey? { let statement = try reader.prepareRowIterator(""" SELECT m.vmAuthority @@ -154,7 +154,7 @@ nonisolated extension Database { // MARK: - Live Supply - - func updateLiveSupply(updates: [ReserveStateUpdate], date: Date) throws { + public func updateLiveSupply(updates: [ReserveStateUpdate], date: Date) throws { try transaction { let table = MintTable() for update in updates { @@ -178,7 +178,7 @@ nonisolated extension Database { // MARK: - Insert - - func insertBalance(quarks: UInt64, mint: PublicKey, costBasis: Double, date: Date) throws { + public func insertBalance(quarks: UInt64, mint: PublicKey, costBasis: Double, date: Date) throws { let table = BalanceTable() // The filter becomes the DO UPDATE's WHERE clause (fork behavior — // see "SQLite.swift Fork" in CLAUDE.md): a conflicting row only @@ -199,7 +199,7 @@ nonisolated extension Database { ) } - func insert(mints: [MintMetadata], date: Date) throws { + public func insert(mints: [MintMetadata], date: Date) throws { try transaction { for mint in mints { try $0.insert(mint: mint, date: date) diff --git a/Flipcash/Core/Controllers/Database/Database+Blocklist.swift b/FlipcashCore/Sources/FlipcashStore/Database+Blocklist.swift similarity index 87% rename from Flipcash/Core/Controllers/Database/Database+Blocklist.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Blocklist.swift index 21a558c16..97a61db05 100644 --- a/Flipcash/Core/Controllers/Database/Database+Blocklist.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Blocklist.swift @@ -12,7 +12,7 @@ import SQLite nonisolated extension Database { /// The cached blocklist, most-recently-blocked first. - func getBlockedUsers() throws -> [BlockedUserProfile] { + public func getBlockedUsers() throws -> [BlockedUserProfile] { let b = BlocklistTable() let rows = try reader.prepareRowIterator(b.table.order(b.blockedAt.desc)) return try rows.map { row in @@ -26,7 +26,7 @@ nonisolated extension Database { } /// Atomically replace the entire cached blocklist with `users`. - func replaceBlocklist(_ users: [BlockedUserProfile]) throws { + public func replaceBlocklist(_ users: [BlockedUserProfile]) throws { let b = BlocklistTable() try writer.transaction { try writer.run(b.table.delete()) @@ -42,7 +42,7 @@ nonisolated extension Database { } /// Insert or replace one blocked user (optimistic block). - func upsertBlockedUser(_ user: BlockedUserProfile) throws { + public func upsertBlockedUser(_ user: BlockedUserProfile) throws { let b = BlocklistTable() try writer.run(b.table.upsert( b.userID <- user.userID, @@ -54,7 +54,7 @@ nonisolated extension Database { } /// Remove one blocked user (optimistic unblock). - func deleteBlockedUser(userID: UserID) throws { + public func deleteBlockedUser(userID: UserID) throws { let b = BlocklistTable() try writer.run(b.table.filter(b.userID == userID).delete()) } diff --git a/Flipcash/Core/Controllers/Database/Database+ContactSync.swift b/FlipcashCore/Sources/FlipcashStore/Database+ContactSync.swift similarity index 80% rename from Flipcash/Core/Controllers/Database/Database+ContactSync.swift rename to FlipcashCore/Sources/FlipcashStore/Database+ContactSync.swift index a94d67a2e..74663170e 100644 --- a/Flipcash/Core/Controllers/Database/Database+ContactSync.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+ContactSync.swift @@ -13,13 +13,17 @@ nonisolated extension Database { /// The contact-sync state machine's persisted cursor. /// A `nil` checksum indicates first-run state. - struct ContactSyncState: Equatable, Sendable { - let checksum: Data? + public struct ContactSyncState: Equatable, Sendable { + public let checksum: Data? - static let empty = ContactSyncState(checksum: nil) + public init(checksum: Data?) { + self.checksum = checksum + } + + public static let empty = ContactSyncState(checksum: nil) } - func contactSyncState() throws -> ContactSyncState { + public func contactSyncState() throws -> ContactSyncState { let table = ContactSyncStateTable() guard let row = try reader.pluck(table.table.filter(table.id == 1)) else { return .empty @@ -27,7 +31,7 @@ nonisolated extension Database { return ContactSyncState(checksum: row[table.checksum]) } - func setContactSyncState(_ state: ContactSyncState) throws { + public func setContactSyncState(_ state: ContactSyncState) throws { let table = ContactSyncStateTable() try writer.transaction { try writer.run( @@ -43,7 +47,7 @@ nonisolated extension Database { // MARK: - Flipcash Contacts - /// Contacts the server has confirmed are on Flipcash, with their DM chat IDs. - func flipcashContacts() throws -> [MatchedContact] { + public func flipcashContacts() throws -> [MatchedContact] { let table = FlipcashContactTable() let rows = try reader.prepareRowIterator(table.table.select(table.e164, table.dmChatId, table.joinTs)) return try rows.map { MatchedContact(e164: $0[table.e164], dmChatID: $0[table.dmChatId], joinDate: $0[table.joinTs]) } @@ -54,7 +58,7 @@ nonisolated extension Database { /// Atomic — readers observe either the old set or the new set, never a partial join. /// Deduplicates on `e164` defensively in case the server ever streams the same number twice. @discardableResult - func replaceFlipcashContacts(_ contacts: [MatchedContact], matchedAt: Date) throws -> Int { + public func replaceFlipcashContacts(_ contacts: [MatchedContact], matchedAt: Date) throws -> Int { let table = FlipcashContactTable() var seen: Set = [] let deduped = contacts.filter { seen.insert($0.e164).inserted } @@ -78,12 +82,17 @@ nonisolated extension Database { /// One row per phone in the last successfully-uploaded contact set. /// `contactId` is `CNContact.identifier` for resolving name/avatar at render time. - struct LocalContact: Equatable, Hashable, Sendable { - let e164: String - let contactId: String + public struct LocalContact: Equatable, Hashable, Sendable { + public let e164: String + public let contactId: String + + public init(e164: String, contactId: String) { + self.e164 = e164 + self.contactId = contactId + } } - func localContactsSnapshot() throws -> [LocalContact] { + public func localContactsSnapshot() throws -> [LocalContact] { let table = LocalContactsSnapshotTable() let rows = try reader.prepareRowIterator(table.table) return try rows.map { row in @@ -92,7 +101,7 @@ nonisolated extension Database { } /// Replace the snapshot with the latest uploaded set. - func replaceLocalContactsSnapshot(_ contacts: [LocalContact]) throws { + public func replaceLocalContactsSnapshot(_ contacts: [LocalContact]) throws { try writer.transaction { try rewriteLocalContactsSnapshot(contacts) } @@ -120,7 +129,7 @@ nonisolated extension Database { // MARK: - Combined writes - /// Replace the snapshot AND upsert the sync state in one transaction. - func updateContactSyncSnapshotAndState( + public func updateContactSyncSnapshotAndState( snapshot contacts: [LocalContact], state: ContactSyncState ) throws { diff --git a/Flipcash/Core/Controllers/Database/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift similarity index 91% rename from Flipcash/Core/Controllers/Database/Database+Conversations.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 42a5bfce6..0f35e6ffa 100644 --- a/Flipcash/Core/Controllers/Database/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -15,7 +15,7 @@ nonisolated extension Database { /// Async wrapper that runs the synchronous cache reads off the caller's /// actor so session start never blocks the main thread on row decoding. - func loadConversationCache() async throws -> (conversations: [Conversation], cursors: [ConversationID: UInt64]) { + public func loadConversationCache() async throws -> (conversations: [Conversation], cursors: [ConversationID: UInt64]) { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { @@ -31,7 +31,7 @@ nonisolated extension Database { /// The persisted per-conversation event-log catch-up frontier (`GetDelta.after_sequence`), omitting /// conversations with no cursor yet. - func getCatchupCursors() throws -> [ConversationID: UInt64] { + public func getCatchupCursors() throws -> [ConversationID: UInt64] { let c = ConversationTable() let rows = try reader.prepareRowIterator(c.table).map { row in (id: ConversationID(data: row[c.id]), cursor: row[c.catchupCursor]) @@ -43,14 +43,14 @@ nonisolated extension Database { /// The persisted catch-up cursor for one conversation (0 when none) — used to re-seat the in-memory /// cursor after a failed message persist so recovery refetches, rather than skips, the window. - func catchupCursor(conversationID: ConversationID) throws -> UInt64 { + public func catchupCursor(conversationID: ConversationID) throws -> UInt64 { let c = ConversationTable() return (try reader.pluck(c.table.filter(c.id == conversationID.data))).flatMap { $0[c.catchupCursor] } ?? 0 } /// The cached DM feed, most-recent activity first, with members and the /// newest stored message as the `lastMessage` preview. - func getConversations() throws -> [Conversation] { + public func getConversations() throws -> [Conversation] { let c = ConversationTable() let m = ConversationMemberTable() @@ -91,7 +91,7 @@ nonisolated extension Database { /// The newest stored non-deleted message for a conversation, or nil when none is cached. Tombstones /// (`kind == 2`) are skipped so the feed preview shows the newest *visible* message rather than a /// blank row for a deleted last message. - func latestMessage(conversationID: ConversationID) throws -> ConversationMessage? { + public func latestMessage(conversationID: ConversationID) throws -> ConversationMessage? { try latestMessage(conversationId: conversationID.data) } @@ -106,7 +106,7 @@ nonisolated extension Database { } /// The newest stored message id (tombstones included) — the mark-read / receive-buzz anchor. - func newestMessageID(conversationID: ConversationID) throws -> MessageID? { + public func newestMessageID(conversationID: ConversationID) throws -> MessageID? { let m = ConversationMessageTable() return try reader.pluck( m.table.filter(m.conversationId == conversationID.data).order(m.id.desc) @@ -116,7 +116,7 @@ nonisolated extension Database { /// The newest stored message (tombstones included). Unlike ``latestMessage(conversationID:)``, a /// delete of the newest message does not regress this value to the previous row — it returns the /// tombstone itself — so identity-keyed triggers (receive buzz, mark-read) don't misfire on deletes. - func newestMessage(conversationID: ConversationID) throws -> ConversationMessage? { + public func newestMessage(conversationID: ConversationID) throws -> ConversationMessage? { let m = ConversationMessageTable() guard let row = try reader.pluck( m.table.filter(m.conversationId == conversationID.data).order(m.id.desc) @@ -128,7 +128,7 @@ nonisolated extension Database { /// Whether a specific message id is already persisted — the "is this a fresh echo?" gate for the /// optimistic-send reconcile, replacing the in-memory existence check. - func messageExists(id: MessageID, conversationID: ConversationID) throws -> Bool { + public func messageExists(id: MessageID, conversationID: ConversationID) throws -> Bool { let m = ConversationMessageTable() return try reader.scalar(m.table.filter(m.conversationId == conversationID.data && m.id == id.value).count) > 0 } @@ -136,7 +136,7 @@ nonisolated extension Database { /// The stored copy of one message, or `nil` if it is not in the local database. Mutations read /// through this rather than the display window, because `expected_event_sequence` must come /// from server truth, never from an optimistic overlay. - func message(id: MessageID, conversationID: ConversationID) throws -> ConversationMessage? { + public func message(id: MessageID, conversationID: ConversationID) throws -> ConversationMessage? { let m = ConversationMessageTable() let query = m.table.filter(m.conversationId == conversationID.data && m.id == id.value).limit(1) @@ -145,7 +145,7 @@ nonisolated extension Database { } /// All cached messages for a conversation, oldest first. - func getConversationMessages(conversationID: ConversationID) throws -> [ConversationMessage] { + public func getConversationMessages(conversationID: ConversationID) throws -> [ConversationMessage] { let m = ConversationMessageTable() let rows = try reader.prepareRowIterator( m.table.filter(m.conversationId == conversationID.data).order(m.id.asc) @@ -156,7 +156,7 @@ nonisolated extension Database { /// A bounded window of a conversation's messages, oldest-first: the newest `limit` when `before` is /// nil, otherwise the `limit` messages immediately older than `before`. Index-backed by the /// composite `(conversationId, id)` primary key — no scan, no sort. - func messagesWindow(conversationID: ConversationID, before: MessageID? = nil, limit: Int) throws -> [ConversationMessage] { + public func messagesWindow(conversationID: ConversationID, before: MessageID? = nil, limit: Int) throws -> [ConversationMessage] { let m = ConversationMessageTable() var query = m.table.filter(m.conversationId == conversationID.data) if let before { @@ -169,7 +169,7 @@ nonisolated extension Database { /// Every message from `startID` (inclusive) to the newest, oldest-first — the id-anchored window. /// Anchoring by id means an arriving message grows the window at the tail instead of sliding the /// oldest revealed row out from under a reader who has scrolled up. - func messages(conversationID: ConversationID, from startID: UInt64) throws -> [ConversationMessage] { + public func messages(conversationID: ConversationID, from startID: UInt64) throws -> [ConversationMessage] { let m = ConversationMessageTable() let rows = try reader.prepareRowIterator( m.table.filter(m.conversationId == conversationID.data && m.id >= startID).order(m.id.asc) @@ -181,7 +181,7 @@ nonisolated extension Database { /// read-pointer advance just crossed. A nil `after` means the pointer had never been set, so the /// whole stored history up to `through` counts as newly read. Index-backed by the composite /// `(conversationId, id)` primary key. - func messages(conversationID: ConversationID, after: MessageID?, through: MessageID) throws -> [ConversationMessage] { + public func messages(conversationID: ConversationID, after: MessageID?, through: MessageID) throws -> [ConversationMessage] { let m = ConversationMessageTable() var query = m.table.filter(m.conversationId == conversationID.data && m.id <= through.value) if let after { @@ -193,7 +193,7 @@ nonisolated extension Database { /// The id `step` rows older than `before` — the next anchor when the reader pages back — falling /// back to the oldest available older row; nil when nothing older is persisted. - func olderAnchor(conversationID: ConversationID, before: UInt64, step: Int) throws -> UInt64? { + public func olderAnchor(conversationID: ConversationID, before: UInt64, step: Int) throws -> UInt64? { let m = ConversationMessageTable() let older = m.table.filter(m.conversationId == conversationID.data && m.id < before) if let row = try reader.pluck(older.order(m.id.desc).limit(1, offset: step - 1)) { @@ -204,14 +204,14 @@ nonisolated extension Database { /// The number of confirmed messages persisted for a conversation — the ceiling the transcript /// window can grow to before older history must be paged from the server. - func messageCount(conversationID: ConversationID) throws -> Int { + public func messageCount(conversationID: ConversationID) throws -> Int { let m = ConversationMessageTable() return try reader.scalar(m.table.filter(m.conversationId == conversationID.data).count) } /// The oldest persisted message id for a conversation, or nil when none is cached — the anchor for /// paging genuinely older history from the server. - func oldestMessageID(conversationID: ConversationID) throws -> MessageID? { + public func oldestMessageID(conversationID: ConversationID) throws -> MessageID? { let m = ConversationMessageTable() return try reader.pluck( m.table.filter(m.conversationId == conversationID.data).order(m.id.asc) @@ -223,7 +223,7 @@ nonisolated extension Database { /// Mirror one type's paged feed load: replaces that type's conversation + member sets (messages /// are retained, other types' conversations untouched), then stores each conversation's /// last-message preview. - func replaceConversationFeed(_ conversations: [Conversation], type: ConversationType) throws { + public func replaceConversationFeed(_ conversations: [Conversation], type: ConversationType) throws { let c = ConversationTable() let m = ConversationMemberTable() let ids = conversations.map(\.id.data) @@ -250,14 +250,14 @@ nonisolated extension Database { /// Advance the persisted catch-up cursor for a conversation without rewriting its members or /// last-message preview. No-ops for a conversation not yet in the feed. - func updateCatchupCursor(_ value: UInt64, for conversationID: ConversationID) throws { + public func updateCatchupCursor(_ value: UInt64, for conversationID: ConversationID) throws { let c = ConversationTable() try writer.run(c.table.filter(c.id == conversationID.data).update(c.catchupCursor <- value)) } /// Upsert one conversation: its row, its members (replaced wholesale), and /// its last-message preview row. - func upsertConversation(_ conversation: Conversation) throws { + public func upsertConversation(_ conversation: Conversation) throws { try writer.transaction { try writeConversation(conversation) } @@ -265,7 +265,7 @@ nonisolated extension Database { /// Upsert messages for a conversation (insert-or-replace on the (conversation, id) key). History is /// retained — the transcript reads a bounded window from it, so there is no prune. - func upsertConversationMessages(_ messages: [ConversationMessage], conversationID: ConversationID) throws { + public func upsertConversationMessages(_ messages: [ConversationMessage], conversationID: ConversationID) throws { try writer.transaction { for message in messages { try writeMessage(message, conversationId: conversationID.data) @@ -279,7 +279,7 @@ nonisolated extension Database { /// established (> 0) and only forward — a catch-up batch's interior checkpoint must not regress a /// cursor a live event already persisted. The conversation row need not exist yet (the update no-ops /// until it does). - func persistMessages(_ messages: [ConversationMessage], cursor: UInt64, conversationID: ConversationID) throws { + public func persistMessages(_ messages: [ConversationMessage], cursor: UInt64, conversationID: ConversationID) throws { let c = ConversationTable() try writer.transaction { for message in messages { @@ -298,7 +298,7 @@ nonisolated extension Database { /// Deletes a conversation's persisted messages — used when a freshly fetched newest page does not /// overlap the retained history, so a stale older epoch can't render seamlessly stitched to the new /// page across an unfetchable gap. - func deleteMessages(conversationID: ConversationID) throws { + public func deleteMessages(conversationID: ConversationID) throws { let m = ConversationMessageTable() try writer.run(m.table.filter(m.conversationId == conversationID.data).delete()) } diff --git a/Flipcash/Core/Controllers/Database/Database+Limits.swift b/FlipcashCore/Sources/FlipcashStore/Database+Limits.swift similarity index 74% rename from Flipcash/Core/Controllers/Database/Database+Limits.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Limits.swift index 2cdabbea7..f90c49df0 100644 --- a/Flipcash/Core/Controllers/Database/Database+Limits.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Limits.swift @@ -11,13 +11,13 @@ nonisolated extension Database { // MARK: - Get - - func getLimits() throws -> Limits? { + public func getLimits() throws -> Limits? { try getSingleton(Limits.self, in: LimitsTable()) } // MARK: - Insert - - func insertLimits(_ limits: Limits) throws { + public func insertLimits(_ limits: Limits) throws { try upsertSingleton(limits, in: LimitsTable()) } } diff --git a/Flipcash/Core/Controllers/Database/Database+Onboarding.swift b/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift similarity index 95% rename from Flipcash/Core/Controllers/Database/Database+Onboarding.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift index a4a2d9a3d..6f256e67b 100644 --- a/Flipcash/Core/Controllers/Database/Database+Onboarding.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift @@ -25,7 +25,7 @@ nonisolated extension Database { /// True once any completed incoming-money activity exists — the "added /// money" milestone. - func hasEverAddedMoney() throws -> Bool { + public func hasEverAddedMoney() throws -> Bool { let a = ActivityTable() return try reader.pluck( a.table.filter( @@ -39,7 +39,7 @@ nonisolated extension Database { /// the activity feed re-syncs in full from the server on every login, while /// chat messages sync lazily per conversation, so an account signed into on /// a fresh database has activity long before it has any messages. - func hasEverTipped(selfUserID: UserID) throws -> Bool { + public func hasEverTipped(selfUserID: UserID) throws -> Bool { try hasEverSentTipActivity() || hasEverSentTipMessage(selfUserID: selfUserID) } diff --git a/Flipcash/Core/Controllers/Database/Database+Profile.swift b/FlipcashCore/Sources/FlipcashStore/Database+Profile.swift similarity index 66% rename from Flipcash/Core/Controllers/Database/Database+Profile.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Profile.swift index 406437d47..45486f301 100644 --- a/Flipcash/Core/Controllers/Database/Database+Profile.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Profile.swift @@ -11,21 +11,21 @@ nonisolated extension Database { // MARK: - Get - - func getProfile() throws -> Profile? { + public func getProfile() throws -> Profile? { try getSingleton(Profile.self, in: ProfileTable()) } - func getUserFlags() throws -> UserFlags? { + public func getUserFlags() throws -> UserFlags? { try getSingleton(UserFlags.self, in: UserFlagsTable()) } // MARK: - Insert - - func insertProfile(_ profile: Profile) throws { + public func insertProfile(_ profile: Profile) throws { try upsertSingleton(profile, in: ProfileTable()) } - func insertUserFlags(_ userFlags: UserFlags) throws { + public func insertUserFlags(_ userFlags: UserFlags) throws { try upsertSingleton(userFlags, in: UserFlagsTable()) } } diff --git a/Flipcash/Core/Controllers/Database/Database+Rates.swift b/FlipcashCore/Sources/FlipcashStore/Database+Rates.swift similarity index 93% rename from Flipcash/Core/Controllers/Database/Database+Rates.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Rates.swift index 4e32786ad..21fe13329 100644 --- a/Flipcash/Core/Controllers/Database/Database+Rates.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Rates.swift @@ -17,7 +17,7 @@ nonisolated extension Database { /// rehydrate its in-memory cache on cold launch so screens render in /// the user's preferred currency before the live mint stream delivers /// its first batch. - func getRates() throws -> [Rate] { + public func getRates() throws -> [Rate] { let table = RateTable() let rows = try reader.prepareRowIterator(""" @@ -37,7 +37,7 @@ nonisolated extension Database { /// Write through a batch of rates from the live mint stream. Each /// row is keyed by currency code, so repeated stream updates for the /// same currency replace the previous row in place. - func upsertRates(_ rates: [Rate]) throws { + public func upsertRates(_ rates: [Rate]) throws { guard !rates.isEmpty else { return } let table = RateTable() diff --git a/Flipcash/Core/Controllers/Database/Database+Singleton.swift b/FlipcashCore/Sources/FlipcashStore/Database+Singleton.swift similarity index 82% rename from Flipcash/Core/Controllers/Database/Database+Singleton.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Singleton.swift index 10126cc26..51a649ed0 100644 --- a/Flipcash/Core/Controllers/Database/Database+Singleton.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Singleton.swift @@ -5,10 +5,10 @@ import Foundation import FlipcashCore -import SQLite +public import SQLite /// A table holding exactly one JSON-encoded row, keyed `id = 1`. -nonisolated protocol SingletonTable { +nonisolated public protocol SingletonTable { static var name: String { get } var table: Table { get } var id: Expression { get } @@ -22,7 +22,7 @@ extension LimitsTable: SingletonTable {} nonisolated extension Database { /// Returns the singleton row decoded as `T`, or `nil` when the table is empty. - func getSingleton(_ type: T.Type, in table: S) throws -> T? { + public func getSingleton(_ type: T.Type, in table: S) throws -> T? { let statement = try reader.prepareRowIterator(""" SELECT t.data @@ -41,7 +41,7 @@ nonisolated extension Database { } /// Encodes `value` and writes it as the singleton row, replacing any existing one. - func upsertSingleton(_ value: T, in table: S) throws { + public func upsertSingleton(_ value: T, in table: S) throws { let data = try JSONEncoder().encode(value) try writer.run( diff --git a/Flipcash/Core/Controllers/Database/Database+UserProfiles.swift b/FlipcashCore/Sources/FlipcashStore/Database+UserProfiles.swift similarity index 85% rename from Flipcash/Core/Controllers/Database/Database+UserProfiles.swift rename to FlipcashCore/Sources/FlipcashStore/Database+UserProfiles.swift index 13944517e..ef4325b7a 100644 --- a/Flipcash/Core/Controllers/Database/Database+UserProfiles.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+UserProfiles.swift @@ -15,7 +15,7 @@ nonisolated extension Database { // MARK: - Get - /// The cached profile for `userID`, or `nil` when it hasn't been fetched yet. - func getUserProfile(userID: UserID) throws -> Profile? { + public func getUserProfile(userID: UserID) throws -> Profile? { let t = UserProfileTable() guard let row = try reader.pluck(t.table.filter(t.userID == userID)) else { return nil @@ -26,7 +26,7 @@ nonisolated extension Database { // MARK: - Insert - /// Cache `profile` under `userID`, replacing any existing row. - func upsertUserProfile(_ profile: Profile, userID: UserID) throws { + public func upsertUserProfile(_ profile: Profile, userID: UserID) throws { let t = UserProfileTable() let data = try JSONEncoder().encode(profile) try writer.run(t.table.upsert( @@ -39,7 +39,7 @@ nonisolated extension Database { // MARK: - Delete - /// Remove the cached profile for `userID`. - func deleteUserProfile(userID: UserID) throws { + public func deleteUserProfile(userID: UserID) throws { let t = UserProfileTable() try writer.run(t.table.filter(t.userID == userID).delete()) } diff --git a/Flipcash/Core/Controllers/Database/Database+VerifiedProtos.swift b/FlipcashCore/Sources/FlipcashStore/Database+VerifiedProtos.swift similarity index 100% rename from Flipcash/Core/Controllers/Database/Database+VerifiedProtos.swift rename to FlipcashCore/Sources/FlipcashStore/Database+VerifiedProtos.swift diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift new file mode 100644 index 000000000..de86c3409 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -0,0 +1,237 @@ +// +// Database.swift +// Code +// +// Created by Dima Bart on 2025-04-11. +// + +import Foundation +import FlipcashCore +public import SQLite + +nonisolated private let logger = Logger(label: "flipcash.database") + +public typealias Expression = SQLite.Expression + +// SQLite.swift serializes reads/writes through each `Connection`'s own +// dispatch queue, so concurrent calls into `reader` and `writer` are safe +// despite Database itself being a reference type. Marking it +// `@unchecked Sendable` lets background write paths (e.g. RatesController's +// rate persistence queue) capture it without escaping Swift 6 isolation. +// The connections themselves are mutable now that they can be closed and +// reopened, so `lock` — not isolation — is what makes that state safe. +// FOLLOW-UP: Remove @unchecked when SQLite.swift declares Connection: Sendable. +// `open` rather than `public` so the test bundle can subclass it to tie temp-file cleanup to the +// database's lifetime. Every member stays `public`, so a subclass can add state but cannot override +// any behaviour. +nonisolated open class Database: @unchecked Sendable { + + private let storeURL: URL + + /// Both are `nil` while the store is closed, and are guarded by `lock` — the two + /// accessors below are the only things that touch them. + private var _reader: Connection? + private var _writer: Connection? + + private let lock = NSLock() + + /// The write connection, opening the store first if it is currently closed. + public var writer: Connection { + get throws { + lock.lock() + defer { lock.unlock() } + + if let existing = _writer { + return existing + } + + let connection = try Self.openWriter(at: storeURL) + _writer = connection + return connection + } + } + + /// The read connection, opening the store first if it is currently closed. + public var reader: Connection { + get throws { + lock.lock() + defer { lock.unlock() } + + if let existing = _reader { + return existing + } + + let connection = try Self.openReader(at: storeURL) + _reader = connection + return connection + } + } + + // MARK: - Init - + + public init(url: URL) throws { + self.storeURL = url + + // Opening both here keeps an unusable store path failing at `init`, where it + // has always failed, rather than deferring it to whichever query runs first. + _ = try writer + _ = try reader + + try createTablesIfNeeded() + } + + private static func openWriter(at url: URL) throws -> Connection { + let connection = try Connection(url.path) + + // Seconds, not milliseconds: SQLite.swift multiplies by 1000 before handing + // the value to `sqlite3_busy_timeout`. + connection.busyTimeout = 2 + + // `journal_mode` is persisted in the database header, but the other two are + // per-connection and have to be set again every time the store is reopened. + try connection.run("PRAGMA journal_mode = WAL;") + try connection.run("PRAGMA cache_size = 10000;") + try connection.run("PRAGMA foreign_keys = ON;") + + return connection + } + + private static func openReader(at url: URL) throws -> Connection { + let connection = try Connection(url.path, readonly: true) + connection.busyTimeout = 2 + return connection + } + + // MARK: - Transaction - + + /// Always inline this function to ensure that captureError + /// captures the function in which this was called, otherwise + /// it will always captured in transaction {} + @inline(__always) + public func transaction(silent: Bool = false, _ block: (Database) throws -> Void) rethrows { + do { + let connection = try writer + let startChangeCount = connection.totalChanges + try connection.transaction { [unowned self] in + try block(self) + } + let endChangeCount = connection.totalChanges + + // There are instances where we want to commit + // the transaction but avoid notifying the UI + // layer of the change. Also, we'll check if + // there's been any changes in this transaction + // to avoid reloading unnecessarily. + if !silent { + let changeDelta = endChangeCount - startChangeCount + if changeDelta > 0 { + NotificationQueue.default.enqueue( + .init( + name: .databaseDidChange, + userInfo: [ + "changeCount": changeDelta, + ] + ), + postingStyle: .asap, + coalesceMask: .onName, + forModes: [.common] + ) + } + } + + } catch { + logger.error("Transaction error", metadata: ["error": "\(error)"]) + } + } + + // MARK: - Lifecycle - + + private static let checkpointPragma = "PRAGMA wal_checkpoint(TRUNCATE);" + + /// Flushes the write-ahead log back into the main database file and truncates it. + /// + /// TRUNCATE rather than PASSIVE: a passive checkpoint gives up silently when any + /// reader is mid-transaction, which is the case that leaves the WAL growing without + /// bound. This blocks up to `busyTimeout` instead, and throws when it cannot finish. + public func checkpoint() throws { + try writer.run(Self.checkpointPragma) + } + + /// Checkpoints the write-ahead log and drops both connections. + /// + /// Dropping the references is what closes the store: SQLite.swift's `Connection` + /// releases its handle from `deinit` and exposes no `close()` of its own. So a + /// connection someone else still holds — a caller partway through `transaction(_:)`, + /// say — closes when that caller returns rather than here. + /// + /// Nothing pairs with this. The next `reader` or `writer` access reopens the store + /// and reapplies the pragmas, which is what lets a close arriving at an awkward + /// moment heal itself instead of leaving the caller with a dead object. + public func close() throws { + lock.lock() + defer { + _reader = nil + _writer = nil + lock.unlock() + } + + // Checkpoint before the writer goes. A WAL left on disk is replayed by whichever + // process opens the store next, which is correct but makes that open cost time + // proportional to the log rather than to what the caller wanted to read. + try _writer?.run(Self.checkpointPragma) + } + + // MARK: - Versioning - + + /// The schema version this build writes. + /// + /// A launch that finds a lower version recorded beside the store deletes the store and rebuilds + /// it from sync, which is the project's substitute for schema migrations. Bump this whenever a + /// table definition in `Schema.swift` changes. + /// + /// This used to be the `SQLiteVersion` key in the app's `Info.plist`. It moved into code because + /// the notification service extension needs the same number to decide whether the store on disk + /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. + /// Both targets link this module, so they cannot disagree. + public static let schemaVersion = 35 + + /// Removes the store and the write-ahead log files beside it. + /// + /// The version file is deliberately left alone: the caller deletes the store because the + /// recorded version is stale, and writes the new one immediately afterwards. + public static func deleteStore(files: StoreLocation.Files) throws { + let urlsToRemove: [URL] = [ + files.database, + files.shm, + files.wal, + ] + + try urlsToRemove.forEach { + if FileManager.default.fileExists(atPath: $0.path) { + try FileManager.default.removeItem(at: $0) + } + } + } + + public static func setUserVersion(version: Int, files: StoreLocation.Files) throws { + try "\(version)".write( + to: files.version, + atomically: true, + encoding: .utf8 + ) + } + + public static func userVersion(files: StoreLocation.Files) throws -> Int? { + let versionString = try String( + contentsOf: files.version, + encoding: .utf8 + ) + + return Int(versionString.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} + +nonisolated extension Notification.Name { + public static let databaseDidChange = Notification.Name("databaseDidChange") +} + diff --git a/FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift b/FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift new file mode 100644 index 000000000..beca61fc3 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift @@ -0,0 +1,105 @@ +// +// ExtensionStore.swift +// FlipcashStore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import SQLite +// For `SQLITE_BUSY`. SQLite.swift re-exports the connection API but not the result codes. +import SQLite3 + +nonisolated private let logger = Logger(label: "flipcash.database.extension") + +/// A bounded open-write-close cycle over the shared store, for callers that are not the app. +/// +/// The app opens the store once at login and keeps it open. An extension cannot: it is woken for a +/// few seconds and then suspended, and a process suspended while holding a lock on App Group storage +/// is the case iOS terminates with `0xdead10cc`. So every write from outside the app opens the store, +/// writes, checkpoints, and closes within one call. +/// +/// The two guards in front of that cycle matter more than the cycle itself, and neither is a +/// nicety — see ``perform(owner:location:fileManager:schemaVersion:_:)``. +nonisolated public enum ExtensionStore { + + public enum Outcome: Equatable { + /// The body ran and the store was checkpointed and closed. + case wrote + /// No store at the shared location. Nothing was created. + case noStore + /// The version recorded beside the store is not the one this build writes. + case versionMismatch(recorded: Int?) + /// Another process held the write lock for longer than the busy timeout. + case busy + case failed(String) + } + + /// Opens the owner's store, runs `body` against it, then checkpoints and closes. + /// + /// Returns rather than throws. Every outcome here is one the caller answers by doing nothing — + /// a notification extension has no way to surface a store problem to the user, and no reason to + /// fail delivery over one. + /// + /// **This never creates a store.** `Database.init` would happily create one, and an empty store + /// appearing at the shared path before the app has migrated is data loss, not an inconvenience: + /// `StoreMigration` reads an existing destination as a finished migration and deletes the legacy + /// store, so the user's history would go with it. The existence check is what prevents that. + /// + /// **It also never writes into a schema it does not match.** A recorded version lower than + /// ``Database/schemaVersion`` means the app has not yet rebuilt the store for this build, and a + /// higher one means a newer build wrote it and the user has since downgraded. Rebuilding belongs + /// to the app, so both cases no-op. + @discardableResult + public static func perform( + owner: PublicKey, + location: StoreLocation = .resolved(), + fileManager: FileManager = .default, + schemaVersion: Int = Database.schemaVersion, + _ body: (Database) throws -> Void + ) -> Outcome { + let files = location.files(owner: owner) + + guard fileManager.fileExists(atPath: files.database.path) else { + return .noStore + } + + let recorded = try? Database.userVersion(files: files) + guard recorded == schemaVersion else { + return .versionMismatch(recorded: recorded) + } + + do { + let database = try Database(url: files.database) + // Checkpoints and drops both connections. Runs even when `body` throws: a store left + // open is the thing this type exists to avoid. + defer { try? database.close() } + try body(database) + return .wrote + + } catch let error as SQLite.Result where error.isBusy { + // The app holds the write lock. Giving up is correct — whatever this write was carrying, + // the app is in a better position to fetch it than the extension is to wait for it. + return .busy + + } catch { + logger.error("Extension store write failed", metadata: ["error": "\(error)"]) + return .failed("\(error)") + } + } +} + +extension SQLite.Result { + + /// Whether this is `SQLITE_BUSY`, under either the primary or an extended result code. + /// + /// Extended codes carry the primary code in their low byte, so one mask covers both shapes. + var isBusy: Bool { + let code: Int32 = switch self { + case .error(_, let code, _): code + case .extendedError(_, let extendedCode, _): extendedCode + } + return code & 0xFF == SQLITE_BUSY + } +} diff --git a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift b/FlipcashCore/Sources/FlipcashStore/Models/StoredBalance.swift similarity index 79% rename from Flipcash/Core/Controllers/Database/Models/StoredBalance.swift rename to FlipcashCore/Sources/FlipcashStore/Models/StoredBalance.swift index 5123769bd..ab100dc16 100644 --- a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift +++ b/FlipcashCore/Sources/FlipcashStore/Models/StoredBalance.swift @@ -8,25 +8,25 @@ import Foundation import FlipcashCore -nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { - let quarks: UInt64 - let symbol: String - let name: String - let supplyFromBonding: UInt64? - let sellFeeBps: Int? - let mint: PublicKey - let vmAuthority: PublicKey? - let updatedAt: Date - let imageURL: URL? - let costBasis: Double - - let usdf: FiatAmount - - var id: PublicKey { +nonisolated public struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { + public let quarks: UInt64 + public let symbol: String + public let name: String + public let supplyFromBonding: UInt64? + public let sellFeeBps: Int? + public let mint: PublicKey + public let vmAuthority: PublicKey? + public let updatedAt: Date + public let imageURL: URL? + public let costBasis: Double + + public let usdf: FiatAmount + + public var id: PublicKey { mint } - init(quarks: UInt64, symbol: String, name: String, supplyFromBonding: UInt64?, sellFeeBps: Int?, mint: PublicKey, vmAuthority: PublicKey?, updatedAt: Date, imageURL: URL?, costBasis: Double) throws { + public init(quarks: UInt64, symbol: String, name: String, supplyFromBonding: UInt64?, sellFeeBps: Int?, mint: PublicKey, vmAuthority: PublicKey?, updatedAt: Date, imageURL: URL?, costBasis: Double) throws { self.quarks = quarks self.symbol = symbol self.name = name @@ -69,7 +69,7 @@ nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { } } - func computeExchangedValue(with rate: Rate) -> ExchangedFiat { + public func computeExchangedValue(with rate: Rate) -> ExchangedFiat { .compute( onChainAmount: TokenAmount(quarks: quarks, mint: mint), rate: rate, @@ -79,7 +79,7 @@ nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { /// Computes the appreciation/depreciation of this balance. /// Returns a tuple with the ExchangedFiat (absolute value) and whether it's positive. - func computeAppreciation(with rate: Rate) -> (value: ExchangedFiat, isPositive: Bool) { + public func computeAppreciation(with rate: Rate) -> (value: ExchangedFiat, isPositive: Bool) { let appreciationUSD = usdf.value - Decimal(costBasis) let usdAbs = FiatAmount.usd(abs(appreciationUSD)) @@ -96,7 +96,7 @@ nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { } extension StoredBalance { - enum Error: Swift.Error { + public enum Error: Swift.Error { case missingStoredCoreMintForNonReserveToken } } @@ -111,7 +111,7 @@ nonisolated extension StoredBalance { /// The USD figure the wallet's token card renders for this balance: the /// stored value rounded to the cents a user actually sees. - var displayedUSDF: FiatAmount { + public var displayedUSDF: FiatAmount { usdf.roundedToSmallestUnit() } @@ -126,7 +126,7 @@ nonisolated extension StoredBalance { /// the stack positions cards by index with no per-card position animation, /// so the swap reads as a jump. The name settles cards showing the same /// figure, and no refresh changes a name. - static func walletOrder(_ lhs: StoredBalance, _ rhs: StoredBalance) -> Bool { + public static func walletOrder(_ lhs: StoredBalance, _ rhs: StoredBalance) -> Bool { let lhsDisplayed = lhs.displayedUSDF let rhsDisplayed = rhs.displayedUSDF diff --git a/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift b/FlipcashCore/Sources/FlipcashStore/Models/StoredMintMetadata.swift similarity index 76% rename from Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift rename to FlipcashCore/Sources/FlipcashStore/Models/StoredMintMetadata.swift index d270a7e08..e30663a7b 100644 --- a/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift +++ b/FlipcashCore/Sources/FlipcashStore/Models/StoredMintMetadata.swift @@ -8,39 +8,39 @@ import Foundation import FlipcashCore -nonisolated struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashable { +nonisolated public struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashable { - let mint: PublicKey - let name: String - let symbol: String - let decimals: Int - let bio: String? - let imageURL: URL? - let vmAddress: PublicKey? - let vmAuthority: PublicKey? - let lockDuration: Int? - let currencyConfig: PublicKey? - let liquidityPool: PublicKey? - let seed: PublicKey? - let authority: PublicKey? - let mintVault: PublicKey? - let coreMintVault: PublicKey? - let coreMintFees: PublicKey? - let supplyFromBonding: UInt64? - let sellFeeBps: Int? + public let mint: PublicKey + public let name: String + public let symbol: String + public let decimals: Int + public let bio: String? + public let imageURL: URL? + public let vmAddress: PublicKey? + public let vmAuthority: PublicKey? + public let lockDuration: Int? + public let currencyConfig: PublicKey? + public let liquidityPool: PublicKey? + public let seed: PublicKey? + public let authority: PublicKey? + public let mintVault: PublicKey? + public let coreMintVault: PublicKey? + public let coreMintFees: PublicKey? + public let supplyFromBonding: UInt64? + public let sellFeeBps: Int? - let socialLinks: String? - let billColors: String? + public let socialLinks: String? + public let billColors: String? - let createdAt: Date? + public let createdAt: Date? - let updatedAt: Date + public let updatedAt: Date - var id: PublicKey { + public var id: PublicKey { mint } - init(mint: PublicKey, name: String, symbol: String, decimals: Int, bio: String?, imageURL: URL?, vmAddress: PublicKey?, vmAuthority: PublicKey?, lockDuration: Int?, currencyConfig: PublicKey?, liquidityPool: PublicKey?, seed: PublicKey?, authority: PublicKey?, mintVault: PublicKey?, coreMintVault: PublicKey?, coreMintFees: PublicKey?, supplyFromBonding: UInt64?, sellFeeBps: Int?, socialLinks: String? = nil, billColors: String? = nil, createdAt: Date? = nil, updatedAt: Date) { + public init(mint: PublicKey, name: String, symbol: String, decimals: Int, bio: String?, imageURL: URL?, vmAddress: PublicKey?, vmAuthority: PublicKey?, lockDuration: Int?, currencyConfig: PublicKey?, liquidityPool: PublicKey?, seed: PublicKey?, authority: PublicKey?, mintVault: PublicKey?, coreMintVault: PublicKey?, coreMintFees: PublicKey?, supplyFromBonding: UInt64?, sellFeeBps: Int?, socialLinks: String? = nil, billColors: String? = nil, createdAt: Date? = nil, updatedAt: Date) { self.mint = mint self.name = name self.symbol = symbol @@ -68,7 +68,7 @@ nonisolated struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashab extension StoredMintMetadata { /// Converts StoredMintMetadata to MintMetadata - var metadata: MintMetadata { + public var metadata: MintMetadata { let vmMetadata: VMMetadata? = { guard let vmAddress = vmAddress, let vmAuthority = vmAuthority, @@ -134,14 +134,14 @@ extension StoredMintMetadata { extension StoredMintMetadata { /// Returns the JSON string persisted in the `socialLinks` column, or `nil` when empty. - nonisolated static func encodedSocialLinks(_ socialLinks: [SocialLink]) -> String? { + public nonisolated static func encodedSocialLinks(_ socialLinks: [SocialLink]) -> String? { guard !socialLinks.isEmpty, let data = try? JSONEncoder().encode(socialLinks) else { return nil } return String(data: data, encoding: .utf8) } /// Returns the JSON string persisted in the `billColors` column, or `nil` when empty. - nonisolated static func encodedBillColors(_ billColors: [String]) -> String? { + public nonisolated static func encodedBillColors(_ billColors: [String]) -> String? { guard !billColors.isEmpty, let data = try? JSONEncoder().encode(billColors) else { return nil } return String(data: data, encoding: .utf8) @@ -150,7 +150,7 @@ extension StoredMintMetadata { /// Creates a StoredMintMetadata from a MintMetadata for immediate display. /// Used when navigating from screens that already have the full metadata /// (e.g. Currency Discovery) to avoid a loading flash. - init(_ metadata: MintMetadata) { + public init(_ metadata: MintMetadata) { let encodedSocialLinks = Self.encodedSocialLinks(metadata.socialLinks) let encodedBillColors = Self.encodedBillColors(metadata.billColors) diff --git a/Flipcash/Core/Controllers/Database/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift similarity index 58% rename from Flipcash/Core/Controllers/Database/Schema.swift rename to FlipcashCore/Sources/FlipcashStore/Schema.swift index 0027b95ec..c27720099 100644 --- a/Flipcash/Core/Controllers/Database/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -8,287 +8,325 @@ import Foundation import FlipcashCore // @preconcurrency: SQLite.swift's Table and Expression not Sendable upstream. -@preconcurrency import SQLite +@preconcurrency public import SQLite -nonisolated struct BalanceTable: Sendable { - static let name = "balance" +nonisolated public struct BalanceTable: Sendable { + public static let name = "balance" - let table = Table(Self.name) - let quarks = Expression ("quarks") - let mint = Expression ("mint") - let costBasis = Expression ("costBasis") - let updatedAt = Expression ("updatedAt") + public init() {} + + public let table = Table(Self.name) + public let quarks = Expression ("quarks") + public let mint = Expression ("mint") + public let costBasis = Expression ("costBasis") + public let updatedAt = Expression ("updatedAt") } -nonisolated struct MintTable: Sendable { - static let name = "mint" +nonisolated public struct MintTable: Sendable { + public static let name = "mint" + + public init() {} - let table = Table(Self.name) - let mint = Expression ("mint") - let name = Expression ("name") - let symbol = Expression ("symbol") - let decimals = Expression ("decimals") - let bio = Expression ("bio") - let imageURL = Expression ("imageURL") + public let table = Table(Self.name) + public let mint = Expression ("mint") + public let name = Expression ("name") + public let symbol = Expression ("symbol") + public let decimals = Expression ("decimals") + public let bio = Expression ("bio") + public let imageURL = Expression ("imageURL") - let vmAddress = Expression ("vmAddress") - let vmAuthority = Expression ("vmAuthority") - let lockDuration = Expression ("lockDuration") + public let vmAddress = Expression ("vmAddress") + public let vmAuthority = Expression ("vmAuthority") + public let lockDuration = Expression ("lockDuration") - let currencyConfig = Expression ("currencyConfig") - let liquidityPool = Expression ("liquidityPool") - let seed = Expression ("seed") - let authority = Expression ("authority") - let mintVault = Expression ("mintVault") - let coreMintVault = Expression ("coreMintVault") - let coreMintFees = Expression ("coreMintFees") - let supplyFromBonding = Expression ("supplyFromBonding") - let sellFeeBps = Expression ("sellFeeBps") - - let socialLinks = Expression ("socialLinks") - let billColors = Expression ("billColors") - - let createdAt = Expression ("createdAt") - - let updatedAt = Expression ("updatedAt") + public let currencyConfig = Expression ("currencyConfig") + public let liquidityPool = Expression ("liquidityPool") + public let seed = Expression ("seed") + public let authority = Expression ("authority") + public let mintVault = Expression ("mintVault") + public let coreMintVault = Expression ("coreMintVault") + public let coreMintFees = Expression ("coreMintFees") + public let supplyFromBonding = Expression ("supplyFromBonding") + public let sellFeeBps = Expression ("sellFeeBps") + + public let socialLinks = Expression ("socialLinks") + public let billColors = Expression ("billColors") + + public let createdAt = Expression ("createdAt") + + public let updatedAt = Expression ("updatedAt") } -nonisolated struct ActivityTable: Sendable { - static let name = "activity" +nonisolated public struct ActivityTable: Sendable { + public static let name = "activity" + + public init() {} - let table = Table(Self.name) - let id = Expression ("id") - let kind = Expression ("kind") - let state = Expression ("state") - let title = Expression ("title") - let quarks = Expression ("quarks") // on-chain mint-native quarks - let nativeAmount = Expression ("nativeAmount") - let currency = Expression ("currency") - let mint = Expression ("mint") - let date = Expression ("date") + public let table = Table(Self.name) + public let id = Expression ("id") + public let kind = Expression ("kind") + public let state = Expression ("state") + public let title = Expression ("title") + public let quarks = Expression ("quarks") // on-chain mint-native quarks + public let nativeAmount = Expression ("nativeAmount") + public let currency = Expression ("currency") + public let mint = Expression ("mint") + public let date = Expression ("date") // The peer on a send/receive, for feed-row avatar + name enrichment. Both // nil for non-peer activity (deposits, buys, withdrawals). - let counterpartyUserID = Expression ("counterpartyUserID") - let counterpartyPhone = Expression ("counterpartyPhone") + public let counterpartyUserID = Expression ("counterpartyUserID") + public let counterpartyPhone = Expression ("counterpartyPhone") } -nonisolated struct CashLinkMetadataTable: Sendable { - static let name = "cashLinkMetadata" +nonisolated public struct CashLinkMetadataTable: Sendable { + public static let name = "cashLinkMetadata" - let table = Table(Self.name) - let id = Expression ("id") - let vault = Expression ("vault") - let canCancel = Expression ("canCancel") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let vault = Expression ("vault") + public let canCancel = Expression ("canCancel") } // Side table for `.swapped` activities: the two swap legs + fee. Joined 1:1 to // `activity` by id. The destination amount columns are nullable — a swap that // hasn't executed yet carries only its destination mint. -nonisolated struct SwapMetadataTable: Sendable { - static let name = "swapMetadata" - - let table = Table(Self.name) - let id = Expression ("id") - let fromMint = Expression ("fromMint") - let fromQuarks = Expression ("fromQuarks") - let fromNativeAmount = Expression ("fromNativeAmount") - let fromCurrency = Expression ("fromCurrency") - let toMint = Expression ("toMint") - let toQuarks = Expression ("toQuarks") - let toNativeAmount = Expression ("toNativeAmount") - let toCurrency = Expression ("toCurrency") - let feeNativeAmount = Expression ("feeNativeAmount") - let feeCurrency = Expression ("feeCurrency") - let state = Expression ("state") +nonisolated public struct SwapMetadataTable: Sendable { + public static let name = "swapMetadata" + + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let fromMint = Expression ("fromMint") + public let fromQuarks = Expression ("fromQuarks") + public let fromNativeAmount = Expression ("fromNativeAmount") + public let fromCurrency = Expression ("fromCurrency") + public let toMint = Expression ("toMint") + public let toQuarks = Expression ("toQuarks") + public let toNativeAmount = Expression ("toNativeAmount") + public let toCurrency = Expression ("toCurrency") + public let feeNativeAmount = Expression ("feeNativeAmount") + public let feeCurrency = Expression ("feeCurrency") + public let state = Expression ("state") } -nonisolated struct LimitsTable: Sendable { - static let name = "limits" +nonisolated public struct LimitsTable: Sendable { + public static let name = "limits" + + public init() {} - let table = Table(Self.name) - let id = Expression ("id") - let data = Expression ("data") + public let table = Table(Self.name) + public let id = Expression ("id") + public let data = Expression ("data") } -nonisolated struct RateTable: Sendable { - static let name = "rate" +nonisolated public struct RateTable: Sendable { + public static let name = "rate" - let table = Table(Self.name) - let currency = Expression ("currency") - let data = Expression ("data") + public init() {} + + public let table = Table(Self.name) + public let currency = Expression ("currency") + public let data = Expression ("data") } // Verified exchange-rate proofs, one per fiat currency. -nonisolated struct VerifiedRateTable: Sendable { - static let name = "verified_rate" +nonisolated public struct VerifiedRateTable: Sendable { + public static let name = "verified_rate" + + public init() {} - let table = Table(Self.name) - let currency = Expression ("currency") - let rateProto = Expression ("rateProto") + public let table = Table(Self.name) + public let currency = Expression ("currency") + public let rateProto = Expression ("rateProto") } -nonisolated struct ProfileTable: Sendable { - static let name = "profile" +nonisolated public struct ProfileTable: Sendable { + public static let name = "profile" - let table = Table(Self.name) - let id = Expression ("id") - let data = Expression ("data") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let data = Expression ("data") } /// Cache of *other* users' profiles, keyed by user id. Populated cache-through /// as profiles are fetched for display (chat counterparts, tip recipients, /// blocked users). The signed-in user's own profile stays in the singleton /// `profile` table. Stored as a JSON blob — reads are only ever by-key. -nonisolated struct UserProfileTable: Sendable { - static let name = "user_profile" +nonisolated public struct UserProfileTable: Sendable { + public static let name = "user_profile" + + public init() {} - let table = Table(Self.name) - let userID = Expression ("userID") // PK - let data = Expression ("data") // JSON-encoded Profile + public let table = Table(Self.name) + public let userID = Expression ("userID") // PK + public let data = Expression ("data") // JSON-encoded Profile } -nonisolated struct UserFlagsTable: Sendable { - static let name = "userFlags" +nonisolated public struct UserFlagsTable: Sendable { + public static let name = "userFlags" - let table = Table(Self.name) - let id = Expression ("id") - let data = Expression ("data") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let data = Expression ("data") } -nonisolated struct BlocklistTable: Sendable { - static let name = "blocklist" +nonisolated public struct BlocklistTable: Sendable { + public static let name = "blocklist" + + public init() {} - let table = Table(Self.name) - let userID = Expression ("userID") // PK - let blockedAt = Expression ("blockedAt") // timeIntervalSinceReferenceDate - let displayName = Expression ("displayName") - let avatarBlurhash = Expression ("avatarBlurhash") + public let table = Table(Self.name) + public let userID = Expression ("userID") // PK + public let blockedAt = Expression ("blockedAt") // timeIntervalSinceReferenceDate + public let displayName = Expression ("displayName") + public let avatarBlurhash = Expression ("avatarBlurhash") } // Verified reserve-state proofs, one per mint. -nonisolated struct VerifiedReserveTable: Sendable { - static let name = "verified_reserve" +nonisolated public struct VerifiedReserveTable: Sendable { + public static let name = "verified_reserve" + + public init() {} - let table = Table(Self.name) - let mint = Expression ("mint") - let reserveProto = Expression ("reserveProto") + public let table = Table(Self.name) + public let mint = Expression ("mint") + public let reserveProto = Expression ("reserveProto") } // Single-row table holding the contact-sync state machine cursor. // Primary key is always 1. -nonisolated struct ContactSyncStateTable: Sendable { - static let name = "contact_sync_state" +nonisolated public struct ContactSyncStateTable: Sendable { + public static let name = "contact_sync_state" - let table = Table(Self.name) - let id = Expression ("id") - let checksum = Expression ("checksum") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let checksum = Expression ("checksum") } // E.164 phones the server has confirmed are on Flipcash. -nonisolated struct FlipcashContactTable: Sendable { - static let name = "flipcash_contact" - - let table = Table(Self.name) - let e164 = Expression ("e164") - let dmChatId = Expression ("dmChatId") - let joinTs = Expression ("joinTs") - let matchedAt = Expression ("matchedAt") +nonisolated public struct FlipcashContactTable: Sendable { + public static let name = "flipcash_contact" + + public init() {} + + public let table = Table(Self.name) + public let e164 = Expression ("e164") + public let dmChatId = Expression ("dmChatId") + public let joinTs = Expression ("joinTs") + public let matchedAt = Expression ("matchedAt") } // Last contact set uploaded to the server. Joined with CNContactStore at // render time via `contactId` so name/avatar resolution stays current. -nonisolated struct LocalContactsSnapshotTable: Sendable { - static let name = "local_contacts_snapshot" +nonisolated public struct LocalContactsSnapshotTable: Sendable { + public static let name = "local_contacts_snapshot" + + public init() {} - let table = Table(Self.name) - let e164 = Expression ("e164") - let contactId = Expression ("contactId") + public let table = Table(Self.name) + public let e164 = Expression ("e164") + public let contactId = Expression ("contactId") } // DM conversation feed. Members and messages live in their own tables; the // feed's last-message preview is the newest row in `conversation_message`. // Dates are stored as raw `timeIntervalSinceReferenceDate` doubles — decoding // is a struct init instead of the bundled codec's per-row DateFormatter parse. -nonisolated struct ConversationTable: Sendable { - static let name = "conversation" +nonisolated public struct ConversationTable: Sendable { + public static let name = "conversation" - let table = Table(Self.name) - let id = Expression ("id") // 32-byte ChatId - let lastActivity = Expression ("lastActivity") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") // 32-byte ChatId + public let lastActivity = Expression ("lastActivity") // Highest contiguous event-log sequence applied for this chat — the resume // point passed to GetDelta. Nil until the first catch-up establishes one. - let catchupCursor = Expression ("catchupCursor") + public let catchupCursor = Expression ("catchupCursor") // ConversationType raw value; scopes feed replaces and the Tips surfaces. - let type = Expression ("type") + public let type = Expression ("type") // Server-set: the counterpart is on the owner's blocklist. Retained so an // unblock restores the conversation; filtered from the displayed feed. - let isHidden = Expression ("isHidden") + public let isHidden = Expression ("isHidden") // Server-set title, group chats only. Nil for DMs. - let title = Expression ("title") + public let title = Expression ("title") } -nonisolated struct ConversationMemberTable: Sendable { - static let name = "conversation_member" +nonisolated public struct ConversationMemberTable: Sendable { + public static let name = "conversation_member" + + public init() {} - let table = Table(Self.name) - let conversationId = Expression ("conversationId") - let userId = Expression ("userId") - let displayName = Expression ("displayName") - let phoneE164 = Expression ("phoneE164") - let readPointer = Expression ("readPointer") - let readPointerTimestamp = Expression ("readPointerTimestamp") + public let table = Table(Self.name) + public let conversationId = Expression ("conversationId") + public let userId = Expression ("userId") + public let displayName = Expression ("displayName") + public let phoneE164 = Expression ("phoneE164") + public let readPointer = Expression ("readPointer") + public let readPointerTimestamp = Expression ("readPointerTimestamp") // Profile-picture rendition blob ids, when the member has a picture. - let profilePictureBlobID = Expression ("profilePictureBlobID") - let profilePictureThumbnailBlobID = Expression ("profilePictureThumbnailBlobID") + public let profilePictureBlobID = Expression ("profilePictureBlobID") + public let profilePictureThumbnailBlobID = Expression ("profilePictureThumbnailBlobID") // The thumbnail rendition's BlurHash preview, when present. - let profilePictureThumbnailBlurhash = Expression ("profilePictureThumbnailBlurhash") + public let profilePictureThumbnailBlurhash = Expression ("profilePictureThumbnailBlurhash") // The member's claimed handle, when they have one. Carried on the same // profile the feed embeds, so it is cached rather than refetched. - let username = Expression ("username") + public let username = Expression ("username") } // One row per message; cash content is decomposed across the amount columns // the same way `activity` stores ExchangedFiat. -nonisolated struct ConversationMessageTable: Sendable { - static let name = "conversation_message" - - let table = Table(Self.name) - let conversationId = Expression ("conversationId") - let id = Expression ("id") - let senderId = Expression ("senderId") - let kind = Expression ("kind") - let text = Expression ("text") - let quarks = Expression ("quarks") - let nativeAmount = Expression ("nativeAmount") - let currency = Expression ("currency") - let mint = Expression ("mint") +nonisolated public struct ConversationMessageTable: Sendable { + public static let name = "conversation_message" + + public init() {} + + public let table = Table(Self.name) + public let conversationId = Expression ("conversationId") + public let id = Expression ("id") + public let senderId = Expression ("senderId") + public let kind = Expression ("kind") + public let text = Expression ("text") + public let quarks = Expression ("quarks") + public let nativeAmount = Expression ("nativeAmount") + public let currency = Expression ("currency") + public let mint = Expression ("mint") // Cash delivery action (0 = sent, 1 = tipped); nil for non-cash rows. - let cashAction = Expression ("cashAction") - let date = Expression ("date") - let unreadSeq = Expression ("unreadSeq") + public let cashAction = Expression ("cashAction") + public let date = Expression ("date") + public let unreadSeq = Expression ("unreadSeq") // Event-log version of this message's current state; the store applies // last-writer-wins by it. Zero for legacy/optimistic rows. - let eventSequence = Expression ("eventSequence") + public let eventSequence = Expression ("eventSequence") // Stable client identity of an optimistic send, carried onto the server row it reconciles to so a // row keeps one identity across sending → sent and survives a DB round-trip. - let clientMessageID = Expression ("clientMessageID") + public let clientMessageID = Expression ("clientMessageID") // Reserved for the reply feature: written as nil and ignored on read. The column exists now // because the schema version can only be bumped once per rebuild, and adding it later would // cost users a second full resync. - let repliedToId = Expression ("repliedToId") + public let repliedToId = Expression ("repliedToId") // When the sender last edited this message; nil if never edited. - let lastEditedTs = Expression ("lastEditedTs") + public let lastEditedTs = Expression ("lastEditedTs") // Tombstone detail. Both nil for a message that has not been deleted. - let deletedBy = Expression ("deletedBy") - let deletedAt = Expression ("deletedAt") + public let deletedBy = Expression ("deletedBy") + public let deletedAt = Expression ("deletedAt") } // MARK: - Tables - nonisolated extension Database { - func createTablesIfNeeded() throws { + public func createTablesIfNeeded() throws { let balanceTable = BalanceTable() let mintTable = MintTable() let activityTable = ActivityTable() @@ -569,7 +607,7 @@ nonisolated extension UInt64: @retroactive Value { } } -nonisolated extension Key32: @retroactive Value { +nonisolated extension Key32: Value { public static var declaredDatatype: String { Blob.declaredDatatype } @@ -583,7 +621,7 @@ nonisolated extension Key32: @retroactive Value { } } -nonisolated extension CurrencyCode: @retroactive Value { +nonisolated extension CurrencyCode: Value { public static var declaredDatatype: String { String.declaredDatatype } diff --git a/FlipcashCore/Sources/FlipcashStore/StoreMigration.swift b/FlipcashCore/Sources/FlipcashStore/StoreMigration.swift new file mode 100644 index 000000000..fc53ff9f6 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashStore/StoreMigration.swift @@ -0,0 +1,150 @@ +// +// StoreMigration.swift +// Code +// + +import Foundation +import FlipcashCore +import SQLite + +nonisolated private let logger = Logger(label: "flipcash.database.migration") + +/// Moves an existing store out of the app's private Application Support directory and into the App +/// Group container, once, on the first launch of a build that knows about the shared location. +/// +/// Two properties matter more than speed here. It has to survive being interrupted partway through — +/// the process can be killed between any two file operations — and it has to survive a user who +/// never launches the old build again, which is why nothing is left behind for a later pass to +/// finish. +nonisolated public enum StoreMigration { + + public enum Outcome: Equatable { + /// Nothing to do: no store in either location, or the two locations are the same directory. + case notNeeded + /// A store was already at the shared location. Any legacy remnants were swept. + case alreadyMigrated + /// A legacy store was checkpointed and moved. + case migrated + /// The move failed. Anything this migration had put at the destination was removed, so the + /// caller opens a fresh store rather than a half-moved one. + case failed(String) + } + + /// Migrates the owner's store into `location.directory` if it is not already there. + /// + /// Never throws. A migration that cannot complete degrades to a fresh store, which the app + /// re-syncs; throwing here would instead block login on a file-system problem. + public static func migrateIfNeeded( + owner: PublicKey, + location: StoreLocation, + fileManager: FileManager = .default + ) -> Outcome { + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + + // The App Group container was unavailable, so `resolved` fell back and both sides name the + // same paths. Moving a file onto itself fails; there is also nothing to move. + guard current.database != legacy.database else { + return .notNeeded + } + + let destinationExists = fileManager.fileExists(atPath: current.database.path) + let legacyExists = fileManager.fileExists(atPath: legacy.database.path) + + guard destinationExists || legacyExists else { + return .notNeeded + } + + do { + if destinationExists { + // Either a finished migration or one that stopped after the database landed. The + // destination store is authoritative either way; the legacy copy is stale from the + // moment the first write goes to the new one, so it is removed rather than merged. + try adoptVersionFile(from: legacy, to: current, fileManager: fileManager) + sweep(legacy, fileManager: fileManager) + return .alreadyMigrated + } + + // Fold the write-ahead log into the main database file first. After this the store is a + // single file, which is the only shape that survives a half-completed move: moving a + // database and its `-wal` as two operations can leave the pair split across directories, + // and SQLite reads that as a database with no log rather than as an error. + try checkpoint(at: legacy.database) + + // The version file moves before the database, and the order is the resumable one. If the + // process dies between the two, the next launch sees no database at the destination, + // retries, and finds the version file already there — which `adoptVersionFile` treats as + // done. Moving the database first would instead leave a store at the destination with no + // recorded schema version, which reads as version 0 and triggers a full rebuild. + try adoptVersionFile(from: legacy, to: current, fileManager: fileManager) + try fileManager.moveItem(at: legacy.database, to: current.database) + + sweep(legacy, fileManager: fileManager) + return .migrated + + } catch { + // Only remove what this migration could have created. When the destination store already + // existed on entry it holds real data, and clearing it would be the migration destroying + // the thing it was meant to preserve. + if !destinationExists { + discard(current, fileManager: fileManager) + } + + logger.error( + "Store migration failed", + metadata: ["error": "\(error)", "destinationExisted": "\(destinationExists)"] + ) + return .failed("\(error)") + } + } + + // MARK: - Steps - + + /// Runs a truncating checkpoint against the legacy store and closes it again. + /// + /// The connection is scoped to this function on purpose: SQLite.swift releases its handle from + /// `deinit`, so the store is only closed — and therefore only safe to move — once this returns. + private static func checkpoint(at url: URL) throws { + let connection = try Connection(url.path) + connection.busyTimeout = 2 + try connection.run("PRAGMA wal_checkpoint(TRUNCATE);") + } + + /// Moves the version file to the destination, tolerating a source that is already gone. + /// + /// A missing destination version reads as version 0, which makes the app delete the store it + /// just migrated and rebuild it — so this is not best-effort, unlike the `-wal`/`-shm` sweep. + private static func adoptVersionFile( + from legacy: StoreLocation.Files, + to current: StoreLocation.Files, + fileManager: FileManager + ) throws { + guard !fileManager.fileExists(atPath: current.version.path) else { + // Already moved, by this migration's earlier interrupted run. + return + } + guard fileManager.fileExists(atPath: legacy.version.path) else { + // No recorded version anywhere. The caller's version check writes one. + return + } + try fileManager.moveItem(at: legacy.version, to: current.version) + } + + /// Removes what the legacy location has left over. Best-effort by design: the store has already + /// moved, so a file that cannot be deleted costs disk space rather than correctness. + /// + /// The `-wal` and `-shm` are not moved. The checkpoint folded the log into the database, and + /// `-shm` is scratch that SQLite rebuilds, so carrying either across would only risk pairing a + /// stale log with a migrated database. + private static func sweep(_ legacy: StoreLocation.Files, fileManager: FileManager) { + for url in [legacy.database, legacy.wal, legacy.shm, legacy.version] { + try? fileManager.removeItem(at: url) + } + } + + private static func discard(_ current: StoreLocation.Files, fileManager: FileManager) { + for url in [current.database, current.wal, current.shm, current.version] { + try? fileManager.removeItem(at: url) + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift index 717d8a0d3..e46ab4a9c 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift @@ -380,14 +380,6 @@ struct ConversationStoreTests { #expect(store.appliedCursor(for: conversationID(1)) == 3) } - @Test("newMessages bumps the conversation's last activity") - func newMessagesBumpsActivity() { - var store = ConversationStore() - store.setFeed([conversation(1, lastActivity: 100), conversation(2, lastActivity: 200)]) - store.apply(.newMessages(conversationID: conversationID(1), messages: [message(5, "yo", at: 500)])) - #expect(store.conversations.first?.id == conversationID(1)) - } - @Test("readPointersChanged advances a member's READ watermark monotonically") func readPointers() { let me = UUID() diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift index 482983a09..a7c1b523c 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift @@ -22,24 +22,6 @@ struct ConversationStreamEventDecodeTests { } } - @Test("New messages decode to a newMessages event") - func newMessages() { - let event = Flipcash_Event_V1_Event.with { - $0.chatUpdate = .with { - $0.chat = .with { $0.value = conversationBytes } - $0.newMessages = .with { $0.messages = [textMessage(1, "hi"), textMessage(2, "yo")] } - } - } - - let decoded = ConversationStreamEvent.decode(event) - #expect(decoded.count == 1) - guard case .newMessages(let conversationID, let messages) = decoded.first else { - Issue.record("expected .newMessages"); return - } - #expect(conversationID == ConversationID(data: conversationBytes)) - #expect(messages.map(\.content) == [.text("hi"), .text("yo")]) - } - @Test("FullRefresh metadata decodes to a metadataRefresh event") func metadataRefresh() { let event = Flipcash_Event_V1_Event.with { @@ -82,12 +64,12 @@ struct ConversationStreamEventDecodeTests { #expect(date == Date(timeIntervalSince1970: 900)) } - @Test("New messages and a metadata update decode to both events in order") + @Test("An event batch and a metadata update decode to both events in order") func combined() { let event = Flipcash_Event_V1_Event.with { $0.chatUpdate = .with { $0.chat = .with { $0.value = conversationBytes } - $0.newMessages = .with { $0.messages = [textMessage(5, "ping")] } + $0.events = .with { $0.events = [.with { $0.sequence = 5; $0.count = 1; $0.mutations = [sentMutation(5, "ping")] }] } $0.metadataUpdates = [.with { $0.lastActivityChanged = .with { $0.newLastActivity = .init(date: Date(timeIntervalSince1970: 1)) } }] @@ -96,7 +78,7 @@ struct ConversationStreamEventDecodeTests { let decoded = ConversationStreamEvent.decode(event) #expect(decoded.count == 2) - if case .newMessages = decoded.first {} else { Issue.record("first should be .newMessages") } + if case .chatEvents = decoded.first {} else { Issue.record("first should be .chatEvents") } if case .lastActivityChanged = decoded.last {} else { Issue.record("last should be .lastActivityChanged") } } @@ -185,13 +167,13 @@ struct ConversationStreamEventDecodeTests { let event = Flipcash_Event_V1_Event.with { $0.chatUpdate = .with { $0.chat = .with { $0.value = conversationBytes } - $0.newMessages = .with { $0.messages = [textMessage(1, "hi")] } + $0.events = .with { $0.events = [.with { $0.sequence = 1; $0.count = 1; $0.mutations = [sentMutation(1, "hi")] }] } $0.isTypingNotifications = .with { $0.isTypingNotifications = [typing(u1, .startedTyping)] } } } let decoded = ConversationStreamEvent.decode(event) #expect(decoded.count == 2) - #expect(decoded.contains { if case .newMessages = $0 { true } else { false } }) + #expect(decoded.contains { if case .chatEvents = $0 { true } else { false } }) #expect(decoded.contains { if case .typingChanged = $0 { true } else { false } }) } @@ -229,33 +211,6 @@ struct ConversationStreamEventDecodeTests { #expect(tombstone.id.value == 3) } - @Test("both events and new_messages present decode to both (additive migration gate)") - func chatEventsAndNewMessagesAdditive() { - let event = Flipcash_Event_V1_Event.with { - $0.chatUpdate = .with { - $0.chat = .with { $0.value = conversationBytes } - $0.newMessages = .with { $0.messages = [textMessage(9, "dup")] } - $0.events = .with { $0.events = [.with { $0.sequence = 9; $0.count = 1; $0.mutations = [sentMutation(9, "dup")] }] } - } - } - let decoded = ConversationStreamEvent.decode(event) - #expect(decoded.contains { if case .chatEvents = $0 { true } else { false } }) - #expect(decoded.contains { if case .newMessages = $0 { true } else { false } }) - } - - @Test("an absent events batch does not suppress new_messages (deprecated path still works)") - func emptyEventsKeepsNewMessages() { - let event = Flipcash_Event_V1_Event.with { - $0.chatUpdate = .with { - $0.chat = .with { $0.value = conversationBytes } - $0.newMessages = .with { $0.messages = [textMessage(1, "keep")] } - } - } - let decoded = ConversationStreamEvent.decode(event) - #expect(decoded.contains { if case .newMessages = $0 { true } else { false } }) - #expect(!decoded.contains { if case .chatEvents = $0 { true } else { false } }) - } - @Test("an event whose only mutation is unrepresentable still carries sequence/count so the cursor advances") func unrepresentableMutationStillAdvances() { let event = Flipcash_Event_V1_Event.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift index 5b00469d0..1893b71a5 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift @@ -64,7 +64,7 @@ struct ProfileTests { /// Profiles persist as a JSON blob in a single-row table, so adding /// `profilePicture` is only safe if rows written before it still decode. - /// This is the whole reason the change ships without a `SQLiteVersion` bump. + /// This is the whole reason the change ships without a `schemaVersion` bump. @Test("A row persisted before profile pictures still decodes") func decodesProfilePersistedBeforeProfilePictures() throws { let legacy = Data(#"{"displayName":"Ted Livingston","email":"ted@example.com"}"#.utf8) @@ -181,7 +181,7 @@ struct ProfileTests { /// Profiles persist as a JSON blob, so `username` is optional and rows /// written before it still decode — which is why this ships without a - /// `SQLiteVersion` bump. + /// `schemaVersion` bump. @Test("A row persisted before usernames still decodes") func decodesProfilePersistedBeforeUsernames() throws { let legacy = Data(#"{"displayName":"Ted Livingston","email":"ted@example.com"}"#.utf8) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift index 314ccf394..55ebd06a2 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift @@ -165,4 +165,84 @@ struct NotificationPayloadTests { let userInfo = [NotificationPayload.userInfoKey: try Self.base64(for: payload)] #expect(NotificationPayload.chatType(userInfo) == nil) } + + // MARK: - chatMessage - + + private static func chatPush( + category: Flipcash_Push_V1_Payload.Category = .chat, + message: Flipcash_Messaging_V1_Message? + ) throws -> [String: String] { + let payload = Flipcash_Push_V1_Payload.with { + $0.category = category + $0.chatMetadata = .with { + $0.type = .contactDm + if let message { $0.message = message } + } + } + return [NotificationPayload.userInfoKey: try Self.base64(for: payload)] + } + + @Test("chatMessage maps the message embedded in the push") + func chatMessageFromMetadata() throws { + let senderUUID = UUID() + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.senderID = .with { $0.value = senderUUID.data } + $0.content = [.with { $0.text = .with { $0.text = "see you there" } }] + $0.ts = .init(date: Date(timeIntervalSince1970: 1_700_000_000)) + $0.eventSequence = 9 + $0.unreadSeq = 4 + } + + let message = try #require(NotificationPayload.chatMessage(try Self.chatPush(message: embedded))) + #expect(message.id == MessageID(value: 42)) + #expect(message.senderID == senderUUID) + #expect(message.content == .text("see you there")) + #expect(message.date == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(message.unreadSeq == 4) + } + + /// The whole reason the embedded message can merge with a fetched one instead of duplicating it. + @Test("chatMessage preserves the event sequence the transcript fetch would return") + func chatMessageCarriesEventSequence() throws { + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.content = [.with { $0.text = .with { $0.text = "hi" } }] + $0.eventSequence = 9 + } + + let message = try #require(NotificationPayload.chatMessage(try Self.chatPush(message: embedded))) + #expect(message.eventSequence == 9) + } + + /// A server that predates the embedded message, which is every server until 0.5.0 ships. + @Test("chatMessage is nil when the push carries no embedded message") + func chatMessageNilWhenAbsent() throws { + #expect(NotificationPayload.chatMessage(try Self.chatPush(message: nil)) == nil) + } + + @Test("chatMessage is nil for a non-chat category even when a message is embedded") + func chatMessageNilForNonChatCategory() throws { + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.content = [.with { $0.text = .with { $0.text = "hi" } }] + } + #expect(NotificationPayload.chatMessage(try Self.chatPush(category: .default, message: embedded)) == nil) + } + + /// `ConversationMessage.init?` rejects content this client can't draw. The accessor has to pass + /// that nil through rather than substituting an empty message. + @Test("chatMessage is nil for embedded content the client cannot represent") + func chatMessageNilForUnrepresentableContent() throws { + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.content = [.with { $0.system = .with { _ in } }] + } + #expect(NotificationPayload.chatMessage(try Self.chatPush(message: embedded)) == nil) + } + + @Test("chatMessage is nil when no payload is present") + func chatMessageNilWhenNoPayload() { + #expect(NotificationPayload.chatMessage([:]) == nil) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift new file mode 100644 index 000000000..58855dc8d --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift @@ -0,0 +1,145 @@ +// +// StoreLocationTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("Store location") +struct StoreLocationTests { + + private let owner = try! PublicKey(Data(repeating: 7, count: 32)) + + private func location(shared: Bool = true) -> StoreLocation { + let root = FileManager.default.temporaryDirectory + return StoreLocation( + directory: root.appendingPathComponent("shared-\(UUID().uuidString)"), + legacyDirectory: root.appendingPathComponent("legacy-\(UUID().uuidString)"), + isShared: shared + ) + } + + // MARK: - File names - + + /// The names have to match what a pre-move install already wrote, or the migration looks in the + /// right directory for the wrong files and silently concludes there is nothing to move. + @Test func fileNamesFollowTheExistingConvention() { + let location = location() + let files = location.files(owner: owner) + let base = "flipcash-\(owner.base58)" + + #expect(files.database.lastPathComponent == "\(base).sqlite") + #expect(files.wal.lastPathComponent == "\(base).sqlite-wal") + #expect(files.shm.lastPathComponent == "\(base).sqlite-shm") + } + + /// The version file has no separator and no extension. It is the one name that looks like a typo + /// and is not — changing it would orphan every existing install's recorded schema version, which + /// reads as "version 0" and triggers a full store rebuild. + @Test func versionFileNameHasNoSeparator() { + let files = location().files(owner: owner) + + #expect(files.version.lastPathComponent == "flipcash-\(owner.base58)version") + #expect(files.version.pathExtension.isEmpty) + } + + @Test func filesLandInTheCurrentDirectory() { + let location = location() + let files = location.files(owner: owner) + + #expect(files.database.deletingLastPathComponent().path == location.directory.path) + #expect(files.version.deletingLastPathComponent().path == location.directory.path) + } + + @Test func legacyFilesLandInTheLegacyDirectory() { + let location = location() + let files = location.legacyFiles(owner: owner) + + #expect(files.database.deletingLastPathComponent().path == location.legacyDirectory.path) + #expect(files.version.deletingLastPathComponent().path == location.legacyDirectory.path) + } + + /// Same owner, same names, different directories — this is what makes the migration a move + /// rather than a rename. + @Test func currentAndLegacyDifferOnlyByDirectory() { + let location = location() + + #expect(location.files(owner: owner).database.lastPathComponent + == location.legacyFiles(owner: owner).database.lastPathComponent) + #expect(location.files(owner: owner).database.path + != location.legacyFiles(owner: owner).database.path) + } + + @Test func differentOwnersGetDifferentFiles() throws { + let location = location() + let other = try PublicKey(Data(repeating: 9, count: 32)) + + #expect(location.files(owner: owner).database.path != location.files(owner: other).database.path) + } + + // MARK: - Resolution - + + /// An unresolvable App Group must not be fatal. The app keeps working out of the legacy + /// directory; what it loses is the extension's ability to see the store. + @Test func missingContainerFallsBackToLegacy() { + let legacy = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-\(UUID().uuidString)") + + let location = StoreLocation.resolved( + legacyDirectory: legacy, + containerURL: { _ in nil } + ) + + #expect(location.isShared == false) + #expect(location.directory.path == legacy.path) + #expect(location.legacyDirectory.path == legacy.path) + } + + /// When the container is missing, current and legacy are the same directory — so a migration + /// asked to run finds its source and destination identical and has to treat that as a no-op + /// rather than moving a file onto itself. + @Test func fallbackMakesCurrentAndLegacyIdentical() { + let legacy = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-\(UUID().uuidString)") + + let location = StoreLocation.resolved( + legacyDirectory: legacy, + containerURL: { _ in nil } + ) + + #expect(location.files(owner: owner) == location.legacyFiles(owner: owner)) + } + + @Test func resolvedContainerBecomesTheStoreDirectory() { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent("container-\(UUID().uuidString)") + let legacy = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-\(UUID().uuidString)") + + let location = StoreLocation.resolved( + legacyDirectory: legacy, + containerURL: { _ in container } + ) + + #expect(location.isShared) + #expect(location.directory.path == container.path) + #expect(location.legacyDirectory.path == legacy.path) + } + + /// The group identifier is passed through untouched — a typo here would silently give the app a + /// container the extensions do not share. + @Test func resolvedPassesTheAppGroupToTheLookup() { + var requested: String? + _ = StoreLocation.resolved(containerURL: { group in + requested = group + return nil + }) + + #expect(requested == StoreLocation.appGroup) + #expect(StoreLocation.appGroup == "group.com.flipcash.shared") + } +} diff --git a/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift b/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift index a3fc78197..b6f845f59 100644 --- a/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift +++ b/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift @@ -7,6 +7,7 @@ import Foundation import Testing @testable import Flipcash import FlipcashCore +import FlipcashStore @Suite("AddMoneyProcessingViewModel — settlement state machine") @MainActor diff --git a/FlipcashTests/AddMoneyGateTests.swift b/FlipcashTests/AddMoneyGateTests.swift index 94098493f..9c5e58853 100644 --- a/FlipcashTests/AddMoneyGateTests.swift +++ b/FlipcashTests/AddMoneyGateTests.swift @@ -7,6 +7,7 @@ import Foundation import Testing @testable import Flipcash import FlipcashCore +import FlipcashStore @Suite("AddMoneyGate") @MainActor struct AddMoneyGateTests { diff --git a/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift index cd5afa020..a5fcf60ff 100644 --- a/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift +++ b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("BuyConfirmationViewModel") diff --git a/FlipcashTests/Chat/ConversationReceiptWiringTests.swift b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift index c824b09f4..b7f335edc 100644 --- a/FlipcashTests/Chat/ConversationReceiptWiringTests.swift +++ b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor @@ -75,11 +76,11 @@ struct ConversationReceiptWiringTests { controller.start() try await waitUntil { mock.streamOpened } - mock.emit(.newMessages(conversationID: ConversationID.test(1), messages: [inboundTip(id: 5, from: them)])) + mock.emit(.sent([inboundTip(id: 5, from: them)], in: ConversationID.test(1))) try await waitUntil { spy.count(of: .tips) == 1 } // The same message delivered again (a reconnect replay) must not re-credit. - mock.emit(.newMessages(conversationID: ConversationID.test(1), messages: [inboundTip(id: 5, from: them)])) + mock.emit(.sent([inboundTip(id: 5, from: them)], in: ConversationID.test(1))) try await Task.sleep(for: .milliseconds(100)) #expect(spy.count(of: .tips) == 1) @@ -125,10 +126,10 @@ struct ConversationReceiptWiringTests { controller.start() try await waitUntil { mock.streamOpened && !controller.conversations.isEmpty } - mock.emit(.newMessages(conversationID: ConversationID.test(1), messages: [ + mock.emit(.sent([ inboundTip(id: 2, from: them), inboundTip(id: 3, from: them), - ])) + ], in: ConversationID.test(1))) try await waitUntil { ((try? database.newestMessageID(conversationID: ConversationID.test(1))) ?? nil) == MessageID(value: 3) } await controller.markRead(conversationID: ConversationID.test(1)) @@ -152,7 +153,7 @@ struct ConversationReceiptWiringTests { controller.start() try await waitUntil { mock.streamOpened && !controller.conversations.isEmpty } - mock.emit(.newMessages(conversationID: ConversationID.test(1), messages: [inboundTip(id: 2, from: them)])) + mock.emit(.sent([inboundTip(id: 2, from: them)], in: ConversationID.test(1))) try await waitUntil { ((try? database.newestMessageID(conversationID: ConversationID.test(1))) ?? nil) == MessageID(value: 2) } await controller.markRead(conversationID: ConversationID.test(1)) diff --git a/FlipcashTests/Chat/ConversationReplySendTests.swift b/FlipcashTests/Chat/ConversationReplySendTests.swift index a27867207..c7a4183ec 100644 --- a/FlipcashTests/Chat/ConversationReplySendTests.swift +++ b/FlipcashTests/Chat/ConversationReplySendTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/Chat/MessageLoaderRevealTests.swift b/FlipcashTests/Chat/MessageLoaderRevealTests.swift index ae688c29b..98798005f 100644 --- a/FlipcashTests/Chat/MessageLoaderRevealTests.swift +++ b/FlipcashTests/Chat/MessageLoaderRevealTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/ContactSyncControllerTests.swift b/FlipcashTests/ContactSyncControllerTests.swift index 0651e5649..f09781f6c 100644 --- a/FlipcashTests/ContactSyncControllerTests.swift +++ b/FlipcashTests/ContactSyncControllerTests.swift @@ -7,6 +7,7 @@ import Contacts import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash // `.serialized` because the first-connect dialog tests mutate the shared @@ -778,7 +779,7 @@ struct ContactSyncControllerTests { #expect(controller.onFlipcashMatchCount == nil) } - @Test("A SQLiteVersion rebuild does not re-fire the first-connect dialog") + @Test("A schemaVersion rebuild does not re-fire the first-connect dialog") func schemaRebuild_doesNotReSignal() async throws { UserDefaults.contactsConnected = nil let contacts = [Self.aliceContact, Self.bobContact] @@ -790,7 +791,7 @@ struct ContactSyncControllerTests { try await firstController.performSync(contacts: contacts) #expect(firstController.onFlipcashMatchCount == 1) - // A SQLiteVersion bump deletes and rebuilds the DB, so the stored + // A schemaVersion bump deletes and rebuilds the DB, so the stored // checksum is gone and this sync takes the first-scan (full upload) // path again — but the durable flag survives, so it must stay silent. let rebuiltMock = MockContactSync() diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 4d0792c74..f24366b3b 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor @@ -505,7 +506,7 @@ struct ConversationControllerTests { try await waitUntil { mock.streamOpened } let message = ConversationMessage(id: MessageID(value: 9), senderID: nil, content: .text("live"), date: Date(timeIntervalSince1970: 0), unreadSeq: 0) - mock.emit(.newMessages(conversationID: ConversationID.test(1), messages: [message])) + mock.emit(.sent([message], in: ConversationID.test(1))) // The stream is consumed on a Task; poll briefly for it to apply. try await waitUntil { !controller.messages(for: ConversationID.test(1)).isEmpty } @@ -534,7 +535,7 @@ struct ConversationControllerTests { ) mock.feed = [existing, newConversation] let message = ConversationMessage(id: MessageID(value: 1), senderID: nil, content: .text("first"), date: Date(timeIntervalSince1970: 200), unreadSeq: 0) - mock.emit(.newMessages(conversationID: ConversationID.test(2), messages: [message])) + mock.emit(.sent([message], in: ConversationID.test(2))) // The stream is consumed on a Task; poll briefly for the hydration. try await waitUntil { controller.conversations.count >= 2 } @@ -956,7 +957,7 @@ struct ConversationControllerTests { let backlog = (1...150).map { ConversationMessage(id: MessageID(value: UInt64($0)), senderID: nil, content: .text("m\($0)"), date: Date(timeIntervalSince1970: TimeInterval($0)), unreadSeq: UInt64($0)) } - mock.emit(.newMessages(conversationID: ConversationID.test(1), messages: backlog)) + mock.emit(.sent(backlog, in: ConversationID.test(1))) // The whole backlog lands in the DB (nothing dropped), but the accessor reads a bounded window. try await waitUntil { ((try? database.messageCount(conversationID: ConversationID.test(1))) ?? 0) == 150 } diff --git a/FlipcashTests/ConversationMutationTests.swift b/FlipcashTests/ConversationMutationTests.swift index d7522072f..2daed5e35 100644 --- a/FlipcashTests/ConversationMutationTests.swift +++ b/FlipcashTests/ConversationMutationTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/Convert/ConvertAmountViewModelTests.swift b/FlipcashTests/Convert/ConvertAmountViewModelTests.swift index a7fe598e4..6a18699f4 100644 --- a/FlipcashTests/Convert/ConvertAmountViewModelTests.swift +++ b/FlipcashTests/Convert/ConvertAmountViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("ConvertAmountViewModel — fee-affordable entry") diff --git a/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift b/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift index a70d420d4..c95c0db1a 100644 --- a/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift +++ b/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("CurrencyPaymentSelectionViewModel") diff --git a/FlipcashTests/CurrencyInfoViewModelTests.swift b/FlipcashTests/CurrencyInfoViewModelTests.swift index 0369bd4c2..ae3be737c 100644 --- a/FlipcashTests/CurrencyInfoViewModelTests.swift +++ b/FlipcashTests/CurrencyInfoViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash /// Pins the loading-state churn behavior behind the Wallet → Currency Info diff --git a/FlipcashTests/Database/Database+BalanceUpsertTests.swift b/FlipcashTests/Database/Database+BalanceUpsertTests.swift index 4c25bf2f3..17d02f749 100644 --- a/FlipcashTests/Database/Database+BalanceUpsertTests.swift +++ b/FlipcashTests/Database/Database+BalanceUpsertTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Balance upsert write gating") @@ -19,9 +20,9 @@ struct DatabaseBalanceUpsertTests { try db.insertBalance(quarks: 1_000, mint: mint, costBasis: 2.5, date: .now) - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.insertBalance(quarks: 1_000, mint: mint, costBasis: 2.5, date: .now + 60) - #expect(db.writer.totalChanges == before) + #expect(try db.writer.totalChanges == before) } @Test("Changed quarks still update the stored balance") @@ -33,9 +34,9 @@ struct DatabaseBalanceUpsertTests { try db.insert(mints: [.makeLaunchpad(address: mint)], date: .now) try db.insertBalance(quarks: 1_000, mint: mint, costBasis: 2.5, date: .now) - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.insertBalance(quarks: 2_000, mint: mint, costBasis: 2.5, date: .now + 60) - #expect(db.writer.totalChanges > before) + #expect(try db.writer.totalChanges > before) #expect(try db.getBalances().first?.quarks == 2_000) } @@ -48,9 +49,9 @@ struct DatabaseBalanceUpsertTests { try db.insert(mints: [.makeLaunchpad(address: mint)], date: .now) try db.insertBalance(quarks: 1_000, mint: mint, costBasis: 2.5, date: .now) - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.insertBalance(quarks: 1_000, mint: mint, costBasis: 3.0, date: .now + 60) - #expect(db.writer.totalChanges > before) + #expect(try db.writer.totalChanges > before) #expect(try db.getBalances().first?.costBasis == 3.0) } @@ -59,8 +60,8 @@ struct DatabaseBalanceUpsertTests { let (db, url) = try Database.makeTemp() defer { Database.removeTemp(at: url) } - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.insertBalance(quarks: 1_000, mint: .jeffy, costBasis: 0, date: .now) - #expect(db.writer.totalChanges > before) + #expect(try db.writer.totalChanges > before) } } diff --git a/FlipcashTests/Database/Database+ContactSyncTests.swift b/FlipcashTests/Database/Database+ContactSyncTests.swift index 4ce140850..60074f5c5 100644 --- a/FlipcashTests/Database/Database+ContactSyncTests.swift +++ b/FlipcashTests/Database/Database+ContactSyncTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Database+ContactSync") diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 39f25d283..b49e720f5 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Conversation offline cache round-trip") diff --git a/FlipcashTests/Database/Database+LiveSupplyTests.swift b/FlipcashTests/Database/Database+LiveSupplyTests.swift index 7d3725b6b..cfb92bc6f 100644 --- a/FlipcashTests/Database/Database+LiveSupplyTests.swift +++ b/FlipcashTests/Database/Database+LiveSupplyTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore import SQLite @testable import Flipcash @@ -87,12 +88,12 @@ struct DatabaseLiveSupplyTests { date: .now ) - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.updateLiveSupply( updates: [ReserveStateUpdate(mint: mint, supplyFromBonding: 500)], date: .now + 60 ) - #expect(db.writer.totalChanges == before) + #expect(try db.writer.totalChanges == before) } @Test("A supply delivered over a NULL column still writes") diff --git a/FlipcashTests/Database/Database+MintUpsertTests.swift b/FlipcashTests/Database/Database+MintUpsertTests.swift index 19885e1bf..475198c90 100644 --- a/FlipcashTests/Database/Database+MintUpsertTests.swift +++ b/FlipcashTests/Database/Database+MintUpsertTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash // `@MainActor` because `StoredMintMetadata.metadata` is main-actor-isolated @@ -146,9 +147,9 @@ struct DatabaseMintUpsertTests { try db.insert(mints: [original], date: .now) let stored = try #require(try db.getMintMetadata(mint: original.address)) - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.insert(mints: [original], date: .now + 60) - #expect(db.writer.totalChanges == before) + #expect(try db.writer.totalChanges == before) #expect(try db.getMintMetadata(mint: original.address) == stored) } @@ -162,9 +163,9 @@ struct DatabaseMintUpsertTests { let renamed = MintMetadata.makeLaunchpad(address: mint, name: "Renamed Token") - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.insert(mints: [renamed], date: .now + 60) - #expect(db.writer.totalChanges > before) + #expect(try db.writer.totalChanges > before) #expect(try db.getMintMetadata(mint: mint)?.name == "Renamed Token") } @@ -176,9 +177,9 @@ struct DatabaseMintUpsertTests { try db.insert(mints: [.makeLaunchpad(address: mint)], date: .now) - let before = db.writer.totalChanges + let before = try db.writer.totalChanges try db.insert(mints: [Self.makeStaticMint(address: mint)], date: .now + 60) - #expect(db.writer.totalChanges > before) + #expect(try db.writer.totalChanges > before) } @Test("Balance is visible after mint upsert without launchpadMetadata") diff --git a/FlipcashTests/Database/Database+OnboardingTests.swift b/FlipcashTests/Database/Database+OnboardingTests.swift index c13c73d6e..34f5e8bd4 100644 --- a/FlipcashTests/Database/Database+OnboardingTests.swift +++ b/FlipcashTests/Database/Database+OnboardingTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite(.serialized) diff --git a/FlipcashTests/Database/Database+ProfileTests.swift b/FlipcashTests/Database/Database+ProfileTests.swift index e10a4c311..6fbe15b29 100644 --- a/FlipcashTests/Database/Database+ProfileTests.swift +++ b/FlipcashTests/Database/Database+ProfileTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Profile + UserFlags offline cache round-trip") diff --git a/FlipcashTests/Database/Database+VerifiedProtosTests.swift b/FlipcashTests/Database/Database+VerifiedProtosTests.swift index c1962c00d..3ccef216b 100644 --- a/FlipcashTests/Database/Database+VerifiedProtosTests.swift +++ b/FlipcashTests/Database/Database+VerifiedProtosTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore @Suite("Database+VerifiedProtos") struct DatabaseVerifiedProtosTests { diff --git a/FlipcashTests/DatabaseLifecycleTests.swift b/FlipcashTests/DatabaseLifecycleTests.swift index 38aa73201..afc1c69d4 100644 --- a/FlipcashTests/DatabaseLifecycleTests.swift +++ b/FlipcashTests/DatabaseLifecycleTests.swift @@ -4,6 +4,7 @@ // import Foundation +import FlipcashStore import Testing @testable import Flipcash @@ -59,4 +60,95 @@ struct DatabaseLifecycleTests { #expect(size(of: walURL) == 0) } + + @Test("closing checkpoints the write-ahead log") + func closeEmptiesWAL() throws { + let (database, walURL) = try makeDatabase() + try database.writer.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + for index in 0..<200 { + try database.writer.run("INSERT INTO probe (value) VALUES (?);", "row-\(index)") + } + #expect(size(of: walURL) > 0) + + try database.close() + + #expect(size(of: walURL) == 0) + } + + @Test("a read after a close reopens the store with its rows intact") + func readAfterCloseReopens() throws { + let (database, _) = try makeDatabase() + try database.writer.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try database.writer.run("INSERT INTO probe (value) VALUES (?);", "kept") + + try database.close() + + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "kept") + } + + @Test("a write after a close lands, and survives a second close") + func writeAfterCloseSurvivesAnotherCycle() throws { + let (database, _) = try makeDatabase() + try database.writer.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + + try database.close() + try database.writer.run("INSERT INTO probe (value) VALUES (?);", "after-close") + try database.close() + + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "after-close") + } + + @Test("a transaction after a close reopens and commits") + func transactionAfterCloseCommits() throws { + let (database, _) = try makeDatabase() + try database.writer.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + + try database.close() + try database.transaction(silent: true) { db in + try db.writer.run("INSERT INTO probe (value) VALUES (?);", "committed") + } + + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "committed") + } + + @Test("the per-connection pragmas are reapplied when the store reopens") + func pragmasAreReappliedOnReopen() throws { + let (database, _) = try makeDatabase() + + try database.close() + + let writer = try database.writer + #expect(try writer.scalar("PRAGMA foreign_keys;") as? Int64 == 1) + #expect(try writer.scalar("PRAGMA cache_size;") as? Int64 == 10_000) + #expect(try writer.scalar("PRAGMA journal_mode;") as? String == "wal") + } + + /// `PRAGMA busy_timeout` reports milliseconds; SQLite.swift's `busyTimeout` property + /// is in seconds and multiplies by 1000 on the way to `sqlite3_busy_timeout`. This is + /// the assertion that catches the two being confused. + @Test("both connections wait two seconds on a busy store, before and after a reopen") + func busyTimeoutIsTwoSeconds() throws { + let (database, _) = try makeDatabase() + + #expect(try database.writer.scalar("PRAGMA busy_timeout;") as? Int64 == 2_000) + #expect(try database.reader.scalar("PRAGMA busy_timeout;") as? Int64 == 2_000) + + try database.close() + + #expect(try database.writer.scalar("PRAGMA busy_timeout;") as? Int64 == 2_000) + #expect(try database.reader.scalar("PRAGMA busy_timeout;") as? Int64 == 2_000) + } + + @Test("closing twice is not an error") + func closeIsIdempotent() throws { + let (database, _) = try makeDatabase() + + try database.close() + try database.close() + + #expect(try database.reader.scalar("SELECT 1;") as? Int64 == 1) + } } diff --git a/FlipcashTests/ExtensionStoreTests.swift b/FlipcashTests/ExtensionStoreTests.swift new file mode 100644 index 000000000..ff1df30ae --- /dev/null +++ b/FlipcashTests/ExtensionStoreTests.swift @@ -0,0 +1,267 @@ +// +// ExtensionStoreTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SQLite +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Extension store writes") +struct ExtensionStoreTests { + + private let owner = try! PublicKey(Data(repeating: 9, count: 32)) + + /// A location standing in for the App Group container. Both directories are the same here: + /// these tests are about what happens once the store has arrived, not about the move. + private func makeLocation() throws -> StoreLocation { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("extension-store-\(UUID().uuidString)") + let group = root.appendingPathComponent("group") + let support = root.appendingPathComponent("support") + try FileManager.default.createDirectory(at: group, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + return StoreLocation(directory: group, legacyDirectory: support, isShared: true) + } + + /// Creates the store the way a logged-in app leaves it: tables built, version recorded. + private func seedStore(at location: StoreLocation, version: Int = Database.schemaVersion) throws { + let files = location.files(owner: owner) + let database = try Database(url: files.database) + try database.close() + try Database.setUserVersion(version: version, files: files) + } + + private func message(id: UInt64, text: String, sequence: UInt64 = 1) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: UUID(), + content: .text(text), + date: Date(timeIntervalSince1970: TimeInterval(id)), + unreadSeq: id, + eventSequence: sequence + ) + } + + private func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + // MARK: - The guards - + + @Test("no store at the shared location is a no-op, and creates nothing") + func missingStoreCreatesNothing() throws { + let location = try makeLocation() + let files = location.files(owner: owner) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run without a store") + } + + #expect(outcome == .noStore) + // The point of the guard. An empty store here reads to `StoreMigration` as a finished + // migration, which would make it delete the legacy store the user's history is still in. + #expect(!exists(files.database)) + #expect(!exists(files.wal)) + #expect(!exists(files.version)) + } + + @Test("a store with no recorded version is left alone") + func missingVersionFileIsSkipped() throws { + let location = try makeLocation() + let files = location.files(owner: owner) + let database = try Database(url: files.database) + try database.close() + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run without a recorded version") + } + + #expect(outcome == .versionMismatch(recorded: nil)) + } + + @Test("a store recorded at an older schema is left for the app to rebuild") + func olderSchemaIsSkipped() throws { + let location = try makeLocation() + try seedStore(at: location, version: Database.schemaVersion - 1) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run against a stale schema") + } + + #expect(outcome == .versionMismatch(recorded: Database.schemaVersion - 1)) + } + + @Test("a store recorded at a newer schema is left alone too") + func newerSchemaIsSkipped() throws { + // A user who installed a newer build and then downgraded. This build's `Schema` is the + // older one, so writing through it could hit a column that no longer means what it did. + let location = try makeLocation() + try seedStore(at: location, version: Database.schemaVersion + 1) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run against a newer schema") + } + + #expect(outcome == .versionMismatch(recorded: Database.schemaVersion + 1)) + } + + // MARK: - Writing - + + @Test("messages written by the extension are there for the app to read") + func writesAreVisibleToTheApp() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(3) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages( + [message(id: 1, text: "first"), message(id: 2, text: "second")], + cursor: 0, + conversationID: conversationID + ) + } + #expect(outcome == .wrote) + + // Reopened the way the app opens it at login, which is the only read that matters. + let app = try Database(url: location.files(owner: owner).database) + let stored = try app.messagesWindow(conversationID: conversationID, limit: 10) + #expect(stored.count == 2) + #expect(stored.map(\.id.value).sorted() == [1, 2]) + try app.close() + } + + @Test("the catch-up cursor does not move") + func cursorIsNotAdvanced() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(4) + + #expect( + ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 9, text: "preview")], cursor: 0, conversationID: conversationID) + } == .wrote + ) + + // The extension fetched a bounded preview, not a delta. A cursor advanced to it would tell + // the app it has everything up to that point and make the next sync skip the gap. + let app = try Database(url: location.files(owner: owner).database) + #expect(try app.catchupCursor(conversationID: conversationID) == 0) + try app.close() + } + + @Test("writing the same preview twice changes nothing") + func repeatedWritesMerge() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(5) + let batch = [message(id: 1, text: "once"), message(id: 2, text: "twice")] + + for _ in 0..<3 { + #expect( + ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages(batch, cursor: 0, conversationID: conversationID) + } == .wrote + ) + } + + let app = try Database(url: location.files(owner: owner).database) + #expect(try app.messagesWindow(conversationID: conversationID, limit: 10).count == 2) + try app.close() + } + + @Test("a stale re-delivery does not overwrite a newer stored message") + func staleDeliveryLoses() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(6) + + _ = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "edited", sequence: 5)], cursor: 0, conversationID: conversationID) + } + _ = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "original", sequence: 2)], cursor: 0, conversationID: conversationID) + } + + let app = try Database(url: location.files(owner: owner).database) + let stored = try app.messagesWindow(conversationID: conversationID, limit: 10) + #expect(stored.count == 1) + if case .text(let text) = stored.first?.content { + #expect(text == "edited") + } else { + Issue.record("expected a text message") + } + try app.close() + } + + // MARK: - Closing - + + @Test("the cycle leaves no write-ahead log behind") + func storeIsCheckpointedAndClosed() throws { + let location = try makeLocation() + try seedStore(at: location) + let files = location.files(owner: owner) + + #expect( + ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "flushed")], cursor: 0, conversationID: .test(7)) + } == .wrote + ) + + // A truncating checkpoint ran and both connections were dropped, so anything left is empty. + // A log with bytes in it would mean the extension went away still holding the store open — + // the shape that gets a process killed with `0xdead10cc`. + let walSize = (try? FileManager.default.attributesOfItem(atPath: files.wal.path)[.size] as? Int) ?? 0 + #expect(walSize == 0) + } + + @Test("a throwing body still closes the store, and commits nothing") + func failureStillCloses() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(8) + + struct Boom: Error {} + let outcome = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "rolled back")], cursor: 0, conversationID: conversationID) + throw Boom() + } + + guard case .failed = outcome else { + Issue.record("expected a failure, got \(outcome)") + return + } + + // `persistMessages` committed its own transaction before the throw, so the row is there. + // What matters is that the store reopens at all, which it cannot if the cycle leaked a + // connection or left the file locked. + let app = try Database(url: location.files(owner: owner).database) + #expect(try app.messagesWindow(conversationID: conversationID, limit: 10).count == 1) + try app.close() + } + + // MARK: - Contention - + + @Test("another process holding the write lock ends the cycle instead of waiting") + func busyLockGivesUp() throws { + let location = try makeLocation() + try seedStore(at: location) + let files = location.files(owner: owner) + + // Stands in for the app mid-transaction. `BEGIN EXCLUSIVE` takes the write lock and holds + // it for as long as this connection is alive. + let holder = try Connection(files.database.path) + try holder.run("PRAGMA journal_mode = WAL;") + try holder.run("BEGIN EXCLUSIVE;") + defer { try? holder.run("ROLLBACK;") } + + let outcome = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "contended")], cursor: 0, conversationID: .test(9)) + } + + #expect(outcome == .busy) + } +} diff --git a/FlipcashTests/HistoryControllerTests.swift b/FlipcashTests/HistoryControllerTests.swift index d644d721f..26d7eaedf 100644 --- a/FlipcashTests/HistoryControllerTests.swift +++ b/FlipcashTests/HistoryControllerTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/MessageLoaderTests.swift b/FlipcashTests/MessageLoaderTests.swift index c1cd457be..80ef671ee 100644 --- a/FlipcashTests/MessageLoaderTests.swift +++ b/FlipcashTests/MessageLoaderTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/RatesControllerTests.swift b/FlipcashTests/RatesControllerTests.swift index 56269564d..933037149 100644 --- a/FlipcashTests/RatesControllerTests.swift +++ b/FlipcashTests/RatesControllerTests.swift @@ -13,6 +13,7 @@ import Testing @preconcurrency import Combine @testable import Flipcash import FlipcashCore +import FlipcashStore import FlipcashAPI @Suite("RatesController") diff --git a/FlipcashTests/Regressions/Regression_69ea049e.swift b/FlipcashTests/Regressions/Regression_69ea049e.swift index ebb12ee5f..886e6b673 100644 --- a/FlipcashTests/Regressions/Regression_69ea049e.swift +++ b/FlipcashTests/Regressions/Regression_69ea049e.swift @@ -17,6 +17,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Regression: 69ea049e – SQLite off main in TransactionHistoryScreen", .bug("69ea049e0174bec1b4390000")) diff --git a/FlipcashTests/Regressions/Regression_69ea28b0.swift b/FlipcashTests/Regressions/Regression_69ea28b0.swift index ed8db639c..9df79b021 100644 --- a/FlipcashTests/Regressions/Regression_69ea28b0.swift +++ b/FlipcashTests/Regressions/Regression_69ea28b0.swift @@ -17,6 +17,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash // `@MainActor` because `StoredMintMetadata.init(_:)` and `.metadata` are diff --git a/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift b/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift index 49a4a04ae..adc68db90 100644 --- a/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift +++ b/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift @@ -18,6 +18,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/Regressions/Regression_sell_max_precision.swift b/FlipcashTests/Regressions/Regression_sell_max_precision.swift index 46f8dc4c2..934c898c9 100644 --- a/FlipcashTests/Regressions/Regression_sell_max_precision.swift +++ b/FlipcashTests/Regressions/Regression_sell_max_precision.swift @@ -12,6 +12,7 @@ import Testing // @preconcurrency: BigDecimal.Rounding not Sendable upstream. @preconcurrency import BigDecimal import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Regression: sell-max precision (newly-minted bonding-curve balance)") diff --git a/FlipcashTests/SessionTests.swift b/FlipcashTests/SessionTests.swift index 2871661a8..94835dfbb 100644 --- a/FlipcashTests/SessionTests.swift +++ b/FlipcashTests/SessionTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/StoreMigrationContainerTests.swift b/FlipcashTests/StoreMigrationContainerTests.swift new file mode 100644 index 000000000..6f39cbda6 --- /dev/null +++ b/FlipcashTests/StoreMigrationContainerTests.swift @@ -0,0 +1,162 @@ +// +// StoreMigrationContainerTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SQLite +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +/// The migration against the containers the app actually uses, rather than two temporary +/// directories standing in for them. +/// +/// `StoreMigrationTests` covers the logic — interruption, sweeping, version adoption — with injected +/// paths. What it cannot cover is the part of a real upgrade that runs before any of that logic: +/// resolving the App Group container. If the entitlement is not active at runtime, +/// `StoreLocation.resolved` falls back to Application Support, both sides name the same file, the +/// migration correctly reports `.notNeeded`, and the extension silently never sees the store. A test +/// that builds its own `StoreLocation` cannot see that failure. +/// +/// Every file here is named from a random owner key. Store paths are owner-scoped +/// (`flipcash-.sqlite`), so nothing here can name a real account's store even though it sits +/// in the real directories, and each test removes what it wrote. +@Suite("Store migration, real containers", .serialized) +struct StoreMigrationContainerTests { + + /// A key no account holds, so these file names cannot collide with a real store. + private func makeOwner() throws -> PublicKey { + var bytes = Data(count: 32) + for index in bytes.indices { + bytes[index] = UInt8.random(in: .min ... .max) + } + return try PublicKey(bytes) + } + + /// The shape a shipped build leaves in Application Support: a WAL-mode store with one row, and + /// the schema version recorded beside it under the name that build writes. + /// + /// Scoped so the connection closes before the migration runs. A live connection would keep the + /// `-shm` on disk and the checkpoint would have company. + private func seedLegacyStore(at files: StoreLocation.Files, value: String) throws { + let connection = try Connection(files.database.path) + try connection.run("PRAGMA journal_mode = WAL;") + try connection.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try connection.run("INSERT INTO probe (value) VALUES (?);", value) + try Database.setUserVersion(version: 35, files: files) + } + + private func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + private func remove(_ groups: StoreLocation.Files...) { + for group in groups { + for url in [group.database, group.wal, group.shm, group.version] { + try? FileManager.default.removeItem(at: url) + } + } + } + + @Test("the App Group container resolves, so the store lands outside the app's own container") + func containerResolves() { + let location = StoreLocation.resolved() + + // The app's private container root, two levels above Application Support. Anything under it + // is visible to this process only, which is the arrangement the move exists to end. The + // container's own path is not checked for the group identifier: the simulator spells it out, + // a device names the directory by UUID instead. + let privateContainer = location.legacyDirectory + .deletingLastPathComponent() + .deletingLastPathComponent() + + #expect(location.isShared) + #expect(location.directory != location.legacyDirectory) + #expect(!location.directory.path.hasPrefix(privateContainer.path)) + } + + @Test("a store in the real Application Support directory moves into the real App Group container") + func upgradeMovesTheStore() throws { + let owner = try makeOwner() + let location = StoreLocation.resolved() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + defer { remove(legacy, current) } + + try FileManager.default.createDirectory( + at: location.legacyDirectory, + withIntermediateDirectories: true + ) + try seedLegacyStore(at: legacy, value: "survived-the-upgrade") + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .migrated) + #expect(exists(current.database)) + #expect(!exists(legacy.database)) + #expect(!exists(legacy.version)) + + // The recorded version has to arrive with the store. 35 is what the shipped build wrote from + // its `SQLiteVersion` Info.plist key and what this build reads from `Database.schemaVersion`; + // if the number did not travel, the launch that just migrated reads 0, decides the schema is + // stale, and deletes the store it moved. + let recorded = (try? Database.userVersion(files: current)) ?? 0 + #expect(recorded == 35) + #expect(Database.schemaVersion <= recorded) + + // Reached through `Database`, which is how the app reads it — and a migrated store arrives as + // a lone `.sqlite` with no `-shm`, the case that needs the writer opened before the reader. + let database = try Database(url: current.database) + defer { try? database.close() } + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "survived-the-upgrade") + } + + @Test("the extension opens the store the app migrated, at the path it resolves for itself") + func extensionReachesTheMigratedStore() throws { + let owner = try makeOwner() + let location = StoreLocation.resolved() + let legacy = location.legacyFiles(owner: owner) + defer { remove(legacy, location.files(owner: owner)) } + + try FileManager.default.createDirectory( + at: location.legacyDirectory, + withIntermediateDirectories: true + ) + try seedLegacyStore(at: legacy, value: "written-by-the-app") + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .migrated) + + // No location passed: `ExtensionStore` resolves the container itself, the way the notification + // service extension does. That it finds this store is the whole point of the move. + var read: String? + let outcome = ExtensionStore.perform(owner: owner) { database in + read = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + try database.writer.run("INSERT INTO probe (value) VALUES (?);", "written-by-the-extension") + } + + #expect(outcome == .wrote) + #expect(read == "written-by-the-app") + + // And back the other way: what the extension wrote is there for the app's next read. + let database = try Database(url: location.files(owner: owner).database) + defer { try? database.close() } + let count = try database.reader.scalar("SELECT count(*) FROM probe;") as? Int64 + #expect(count == 2) + } + + @Test("a push before the first migrated launch does not leave an empty store behind") + func extensionCreatesNothing() throws { + let owner = try makeOwner() + let current = StoreLocation.resolved().files(owner: owner) + defer { remove(current) } + + let outcome = ExtensionStore.perform(owner: owner) { _ in + Issue.record("The body must not run when there is no store at the shared path.") + } + + #expect(outcome == .noStore) + #expect(!exists(current.database)) + } +} diff --git a/FlipcashTests/StoreMigrationTests.swift b/FlipcashTests/StoreMigrationTests.swift new file mode 100644 index 000000000..97b82c367 --- /dev/null +++ b/FlipcashTests/StoreMigrationTests.swift @@ -0,0 +1,251 @@ +// +// StoreMigrationTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SQLite +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Store migration") +struct StoreMigrationTests { + + private let owner = try! PublicKey(Data(repeating: 7, count: 32)) + + /// A location whose two directories are both fresh and both empty, standing in for an App + /// Group container and an Application Support directory. + private func makeLocation() throws -> StoreLocation { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("migration-\(UUID().uuidString)") + let current = root.appendingPathComponent("group") + let legacy = root.appendingPathComponent("support") + try FileManager.default.createDirectory(at: current, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true) + return StoreLocation(directory: current, legacyDirectory: legacy, isShared: true) + } + + /// Writes a store at `url` with one row in it, and closes it again. + private func seedStore(at url: URL, value: String) throws { + let connection = try Connection(url.path) + try connection.run("PRAGMA journal_mode = WAL;") + try connection.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try connection.run("INSERT INTO probe (value) VALUES (?);", value) + } + + /// Read-write on purpose. A migrated store arrives as a lone `.sqlite` — the `-wal` was + /// folded in and the `-shm` swept — and a read-only connection to a WAL-mode database with + /// no `-shm` beside it fails with `unable to open database file`, because it cannot create + /// the shared-memory file it needs. `Database` does not hit this: it opens its writer before + /// its reader, and the writer creates the `-shm`. + private func readProbe(at url: URL) throws -> String? { + let connection = try Connection(url.path) + return try connection.scalar("SELECT value FROM probe LIMIT 1;") as? String + } + + private func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + // MARK: - Migrating - + + @Test("a store in the old location moves, with its rows") + func legacyStoreMoves() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "carried-over") + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .migrated) + #expect(exists(current.database)) + #expect(try readProbe(at: current.database) == "carried-over") + } + + @Test("the app opens the migrated store through Database and reads it") + func migratedStoreOpensThroughDatabase() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "upgrade-path") + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .migrated) + + // Through the type the app actually uses, including its `createTablesIfNeeded` pass, + // which is the step that would fail against a half-moved or truncated file. It also + // covers the writer-before-reader ordering in `Database.init`: a freshly migrated store + // has no `-shm`, and only a writer can create one. + let database = try Database(url: current.database) + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "upgrade-path") + } + + @Test("nothing is left behind in the old location") + func legacyLeftoversAreSwept() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + try seedStore(at: legacy.database, value: "moved") + try "4".write(to: legacy.version, atomically: true, encoding: .utf8) + + _ = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(!exists(legacy.database)) + #expect(!exists(legacy.wal)) + #expect(!exists(legacy.shm)) + #expect(!exists(legacy.version)) + } + + /// The store is deleted and rebuilt whenever the recorded version is older than the build's, + /// and a missing version file reads as 0. A migration that dropped the version file would + /// therefore wipe the store it had just moved. + @Test("the recorded version survives the move") + func versionFileMovesWithTheStore() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "row") + try Database.setUserVersion(version: 9, files: legacy) + + _ = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(try Database.userVersion(files: current) == 9) + } + + @Test("a store with no recorded version still moves") + func missingVersionFileIsNotAnError() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "unversioned") + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .migrated) + #expect(try readProbe(at: current.database) == "unversioned") + } + + /// Rows sitting in the write-ahead log rather than the main database file are the reason the + /// migration checkpoints before it moves anything. + @Test("rows still in the write-ahead log arrive at the destination") + func walContentsAreFoldedInBeforeTheMove() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + + // Held open across the migration: SQLite folds the log back in when the last connection + // to a store closes, so only a connection that is still open leaves a log on disk for + // the migration to find. + var writer: Connection? = try Connection(legacy.database.path) + try writer?.run("PRAGMA journal_mode = WAL;") + try writer?.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try writer?.run("INSERT INTO probe (value) VALUES (?);", "in-the-log") + #expect(exists(legacy.wal)) + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + #expect(outcome == .migrated) + + // Dropped before the read, which is the only reason this reads back at all. The + // migration swept the legacy `-wal` and `-shm` out from under this connection, and the + // moved file is the same inode it still points at, so a second connection opened + // alongside it inherits that half-torn state and throws a disk I/O error. The app never + // reaches this shape: it migrates at launch, before anything has the store open. + writer = nil + + #expect(try readProbe(at: current.database) == "in-the-log") + } + + // MARK: - Not migrating - + + @Test("no store in either place is nothing to do") + func emptyDirectoriesAreNotNeeded() throws { + let location = try makeLocation() + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .notNeeded) + } + + /// When the App Group container does not resolve, `StoreLocation` falls back and both + /// directories name the same paths. Moving a file onto itself fails. + @Test("a fallback location has nothing to move") + func identicalDirectoriesAreNotNeeded() throws { + let base = try makeLocation().legacyDirectory + let location = StoreLocation(directory: base, legacyDirectory: base, isShared: false) + try seedStore(at: location.files(owner: owner).database, value: "in-place") + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .notNeeded) + #expect(try readProbe(at: location.files(owner: owner).database) == "in-place") + } + + @Test("a store already at the destination is kept, and the old one discarded") + func destinationStoreWins() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: current.database, value: "current") + try seedStore(at: legacy.database, value: "stale") + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .alreadyMigrated) + #expect(try readProbe(at: current.database) == "current") + #expect(!exists(legacy.database)) + } + + /// The half-completed move the ordering is designed around: the version file landed, the + /// process died, the database is still in the old directory. + @Test("a move interrupted after the version file finishes on the next run") + func interruptedMoveResumes() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "resumed") + try Database.setUserVersion(version: 3, files: current) + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .migrated) + #expect(try readProbe(at: current.database) == "resumed") + #expect(try Database.userVersion(files: current) == 3) + } + + /// A destination version file is the record of a move that already got that far, so a legacy + /// one left beside it is stale rather than authoritative. + @Test("a version file already at the destination is not overwritten by the old one") + func destinationVersionFileIsAuthoritative() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "row") + try Database.setUserVersion(version: 3, files: current) + try Database.setUserVersion(version: 1, files: legacy) + + _ = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(try Database.userVersion(files: current) == 3) + #expect(!exists(legacy.version)) + } + + // MARK: - Failing - + + @Test("an unreadable legacy store fails without leaving a partial one behind") + func corruptLegacyStoreDegrades() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try Data("not a database".utf8).write(to: legacy.database) + try Database.setUserVersion(version: 5, files: legacy) + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + guard case .failed = outcome else { + Issue.record("expected a failure, got \(outcome)") + return + } + + // Nothing at the destination, so login opens a fresh store rather than one that is + // half of something else. + #expect(!exists(current.database)) + #expect(!exists(current.version)) + } +} + diff --git a/FlipcashTests/StoredBalanceAppreciationTests.swift b/FlipcashTests/StoredBalanceAppreciationTests.swift index 75df972c4..2eb2af551 100644 --- a/FlipcashTests/StoredBalanceAppreciationTests.swift +++ b/FlipcashTests/StoredBalanceAppreciationTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("StoredBalance - Appreciation") diff --git a/FlipcashTests/TestSupport/Conversation+TestSupport.swift b/FlipcashTests/TestSupport/Conversation+TestSupport.swift index 6c4215773..21c700f02 100644 --- a/FlipcashTests/TestSupport/Conversation+TestSupport.swift +++ b/FlipcashTests/TestSupport/Conversation+TestSupport.swift @@ -12,3 +12,18 @@ extension ConversationID { ConversationID(data: Data(repeating: byte, count: 32)) } } + +extension ConversationStreamEvent { + /// A live `.chatEvents` update carrying `messages` as one contiguous run of `.sent` mutations, + /// as the server delivers a send. The run is sequenced from zero, so it lands on a conversation + /// whose frontier the test has not seeded. + static func sent(_ messages: [ConversationMessage], in conversationID: ConversationID) -> ConversationStreamEvent { + .chatEvents(conversationID: conversationID, events: [ + DecodedChatEvent( + sequence: UInt64(messages.count), + count: UInt64(messages.count), + mutations: messages.map { .sent($0) } + ) + ]) + } +} diff --git a/FlipcashTests/TestSupport/Database+TestSupport.swift b/FlipcashTests/TestSupport/Database+TestSupport.swift index f9e929373..26cce02cc 100644 --- a/FlipcashTests/TestSupport/Database+TestSupport.swift +++ b/FlipcashTests/TestSupport/Database+TestSupport.swift @@ -4,6 +4,7 @@ // import Foundation +import FlipcashStore @testable import Flipcash extension Database { diff --git a/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift b/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift index 7d981f62a..a87eb4961 100644 --- a/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift +++ b/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift @@ -6,6 +6,7 @@ import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore import SQLite /// Single-row read helpers used only in tests. Production reads the whole diff --git a/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift b/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift index d3e508a44..c649b910b 100644 --- a/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift +++ b/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift @@ -5,6 +5,7 @@ import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash extension ExchangedBalance { diff --git a/FlipcashTests/TestSupport/MockSession.swift b/FlipcashTests/TestSupport/MockSession.swift index 738d6381b..f69265201 100644 --- a/FlipcashTests/TestSupport/MockSession.swift +++ b/FlipcashTests/TestSupport/MockSession.swift @@ -6,6 +6,7 @@ import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore /// Closure-driven mock conforming to every Session capability protocol. /// Unset handlers throw `MockSessionError.unimplemented`. diff --git a/FlipcashTests/TestSupport/Mocks.swift b/FlipcashTests/TestSupport/Mocks.swift index 178968395..1378c3d6c 100644 --- a/FlipcashTests/TestSupport/Mocks.swift +++ b/FlipcashTests/TestSupport/Mocks.swift @@ -12,6 +12,7 @@ import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore extension Database { /// Fresh, per-access SQLite file. Each read of `.mock` returns a new diff --git a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift index c6b34eb5d..794fe7c84 100644 --- a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift +++ b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash extension SessionContainer { diff --git a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift index 39345f207..ecd62f1d0 100644 --- a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift +++ b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import Testing import FlipcashCore +import FlipcashStore import FlipcashUI @testable import FlipcashCore @testable import Flipcash diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index c503598d6..b2b2e00bb 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -9,6 +9,7 @@ import Contacts import Intents import FlipcashCore import FlipcashAPI +import FlipcashStore /// Rewrites contact pushes to use the user's local contact name, and renders /// "Sent You Cash" pushes as communication notifications carrying the sender's @@ -168,15 +169,23 @@ final class NotificationService: UNNotificationServiceExtension { // `UNNotificationContent`. Keeping the `Task` out of this `self`-isolated method is what lets // the region checker prove the closure crosses no isolation boundary with a non-`Sendable`. delivery.arm(handler: contentHandler, content: finalContent) - Self.startPrefetch(into: delivery, for: conversationID) + Self.startPrefetch( + into: delivery, + for: conversationID, + embedded: NotificationPayload.chatMessage(request.content.userInfo) + ) } /// Spawns the transcript prefetch and registers it on `delivery`. `nonisolated static` and taking /// only `Sendable` arguments, so the spawned `Task` captures nothing isolated to a `self` — which /// is what keeps the region checker satisfied and the hand-off thread-agnostic. - private nonisolated static func startPrefetch(into delivery: DeliveryBox, for conversationID: ConversationID) { + private nonisolated static func startPrefetch( + into delivery: DeliveryBox, + for conversationID: ConversationID, + embedded: ConversationMessage? + ) { let task = Task { - await cachePreview(for: conversationID, deliver: { delivery.deliver() }) + await cachePreview(for: conversationID, embedded: embedded, deliver: { delivery.deliver() }) } delivery.setPrefetchTask(task) } @@ -289,7 +298,11 @@ final class NotificationService: UNNotificationServiceExtension { /// connection. Calls `deliver` once the transcript is cached (or the fetch can't proceed) so the /// banner isn't gated on the slower branding round-trip. Best-effort: any failure just leaves the /// content extension to fetch live. - private static func cachePreview(for conversationID: ConversationID, deliver: @Sendable () -> Void) async { + private static func cachePreview( + for conversationID: ConversationID, + embedded: ConversationMessage?, + deliver: @Sendable () -> Void + ) async { guard let account = OwnerKeyStore.loadOwnerAccount() else { return deliver() } do { let client = try ChatNotificationClient() @@ -299,7 +312,12 @@ final class NotificationService: UNNotificationServiceExtension { limit: NotificationPreviewCache.previewLimit, retryingEmpty: true ) - guard !messages.isEmpty else { return deliver() } + guard !messages.isEmpty else { + // The fetch came back empty but the push still carried a message. Write that one + // rather than nothing. + await persist(merge(fetched: [], embedded: embedded), for: conversationID, account: account) + return deliver() + } func items(_ branding: [PublicKey: MintBrandingInfo]) -> [ChatItem] { ChatItem.preview( from: messages, @@ -312,6 +330,12 @@ final class NotificationService: UNNotificationServiceExtension { // round-trip, then enrich the cache with token names + icons best-effort — the bubble // renders fine without branding if it's slow or the extension is suspended first. NotificationPreviewCache.write(items([:]), for: conversationID) + + // Before `deliver()`, not after. The banner pays for the write — a few milliseconds plus + // roughly 100 ms of checkpoint — but after delivery there is nothing keeping the process + // alive, and being suspended mid-write while holding the App Group store's lock is the + // case iOS kills with `0xdead10cc`. + await persist(merge(fetched: messages, embedded: embedded), for: conversationID, account: account) deliver() let branding = (try? await client.resolveMintBranding(in: messages)) ?? [:] if !branding.isEmpty { @@ -319,9 +343,116 @@ final class NotificationService: UNNotificationServiceExtension { } } catch { // Best-effort prefetch — a transport failure: the content extension falls back to a live - // fetch on open. + // fetch on open. The store write does not fall back, because the embedded message needs + // no transport: an offline device still lands the message the push carried. ExtensionReporting.capture(error, reason: "Notification preview prefetch failed") + await persist(merge(fetched: [], embedded: embedded), for: conversationID, account: account) deliver() } } + + /// The messages to write, preferring the fetched copy of any message the push also embedded. + /// + /// The two sources overlap by exactly one message in the ordinary case — the push embeds what it + /// is notifying about, and the fetch returns that as its newest. They agree on `eventSequence`, + /// so writing both would converge anyway; deduplicating by ID keeps the write to one row per + /// message and keeps the fetched copy, which is the one the server rendered most recently. + private static func merge( + fetched: [ConversationMessage], + embedded: ConversationMessage? + ) -> [ConversationMessage] { + guard let embedded else { return fetched } + guard !fetched.contains(where: { $0.id == embedded.id }) else { return fetched } + return fetched + [embedded] + } + + /// Writes the messages this push produced into the shared store, so the app has them on next + /// launch instead of fetching them after the user opens the chat. + /// + /// The side-car cache is still written above and still owns the content extension's expand. This + /// is a second destination, not a replacement: the two have different readers, different + /// lifetimes, and the side-car does not need the store's schema to be current. + /// + /// `cursor: 0` on purpose. Advancing the catch-up cursor would tell the app it has everything up + /// to this point, and the extension fetched a bounded preview rather than a delta — the app would + /// skip the gap on its next sync. Messages merge on `eventSequence`, so writing the same preview + /// twice, or writing one the app already has, changes nothing. + /// + /// No conversation row is synthesized. A conversation the app has never seen stays absent from + /// the feed until sync introduces it; these rows are a warm transcript for a chat the user + /// already has, not a way to invent one. + private static func persist( + _ messages: [ConversationMessage], + for conversationID: ConversationID, + account: UserAccount + ) async { + // Nothing to write: no fetch and no embedded message. Opening the store to write zero rows + // would still cost a checkpoint. + guard !messages.isEmpty else { return } + + let owner = account.keyAccount.ownerPublicKey + + await withCheckedContinuation { (continuation: CheckedContinuation) in + let resume = OneShot { continuation.resume() } + + // The assertion is the mitigation: it asks the system to hold off suspension while the + // store is open. `performExpiringActivity` runs the block on its own queue and calls it a + // second time, on another thread, when the assertion is being revoked — hence `OneShot`, + // since resuming a continuation twice traps. + ProcessInfo.processInfo.performExpiringActivity(withReason: "flipcash.notification.store-write") { expired in + guard !expired else { + // Either the write already finished (and resumed), or it is mid-transaction, + // where interrupting it is worse than letting it commit. Just unblock the caller. + resume.fire() + return + } + + let outcome = ExtensionStore.perform(owner: owner) { database in + try database.persistMessages(messages, cursor: 0, conversationID: conversationID) + } + + switch outcome { + case .wrote, .noStore, .busy: + // All three are ordinary. `noStore` is a user who has not finished login on a + // build that owns the shared store; `busy` is the app holding the write lock, + // which means the app is running and will fetch this itself. + ExtensionReporting.breadcrumb("store write: \(outcome)") + case .versionMismatch(let recorded): + // The app rebuilds the store on its next launch. Worth a breadcrumb because a + // mismatch that persists means preload is silently off for this user. + ExtensionReporting.breadcrumb("store write skipped, schema \(recorded.map(String.init) ?? "none") != \(Database.schemaVersion)") + case .failed(let description): + ExtensionReporting.capture( + StoreWriteError.failed(description), + reason: "Notification store write failed" + ) + } + + resume.fire() + } + } + } + + private enum StoreWriteError: Error { + case failed(String) + } + + /// Runs its closure at most once, whichever thread gets there first. + private final class OneShot: @unchecked Sendable { + private let lock = NSLock() + private var action: (() -> Void)? + + init(_ action: @escaping () -> Void) { + self.action = action + } + + func fire() { + let captured = lock.withLock { () -> (() -> Void)? in + defer { action = nil } + return action + } + captured?() + } + } + }