From cd95b3e8934f9f6c75e8c6d613cedac30bb6f252 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 21:09:40 -0400 Subject: [PATCH] refactor(store): move the persistence layer into a shared FlipcashStore package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification service extension cannot reach `Database` while it lives in the app target. Xcode's synchronised file groups do not offer a way in: all three `PBXFileSystemSynchronizedBuildFileExceptionSet` entries in this project list `Info.plist` as an exclusion, which is the only thing the mechanism does. A file cannot join a second target that way, so sharing code with the extension means a package. `Flipcash/Core/Controllers/Database/` becomes `FlipcashStore`, a new target in `FlipcashCore` linked into both `Flipcash` and `NotificationService`. It is separate from `FlipcashCore` rather than part of it so that everything depending on the models does not also pull in SQLite. The SQLite.swift dependency is branch-pinned to match the app project's reference to the same fork, because SPM resolves one version of it for the whole graph. The move is mechanical: types the app target already used become `public`, and `import FlipcashStore` is added to the 28 app files and 38 test files that read through the database. Two things did not move cleanly: - `Updateable` stayed in the app target. It is a SwiftUI `@Observable` wrapper that re-queries on `.databaseDidChange`, not persistence. The app target defaults new types to `@MainActor` and a package target does not, so in the package its `init` could not send `self` into a `@MainActor` task. - `Database` is `open` rather than `public`, so the test bundle's `TempDatabase` can keep tying temp-file cleanup to the database's lifetime. Its members stay `public`: a subclass can add state but cannot override behaviour. No behaviour change. The database tests stay in the app test bundle so they keep running on the simulator — the read-only-WAL and same-inode failures this store hits are iOS-specific and do not reproduce on macOS. --- Code.xcodeproj/project.pbxproj | 10 + .../Controllers/BlocklistController.swift | 1 + .../Core/Controllers/ContactDirectory.swift | 1 + .../Controllers/ContactSyncController.swift | 1 + .../Controllers/ConversationController.swift | 1 + .../Core/Controllers/HistoryController.swift | 1 + .../Core/Controllers/RatesController.swift | 1 + .../{Database => }/Updateable.swift | 0 .../Screens/Main/AddMoney/AddMoneyGate.swift | 1 + .../AddMoneyProcessingViewModel.swift | 1 + .../Screens/Main/Bill/BillValuation.swift | 1 + .../Screens/Main/Buy/BuyAmountViewModel.swift | 1 + .../Main/Buy/BuyConfirmationScreen.swift | 1 + .../Main/Buy/BuyConfirmationViewModel.swift | 1 + .../Core/Screens/Main/Buy/BuyFlowPath.swift | 1 + .../ConvertAmountScreen.swift | 1 + .../ConvertAmountViewModel.swift | 1 + .../CurrencyCreationWizardScreen.swift | 1 + .../CurrencyPaymentSelectionViewModel.swift | 1 + .../Currency Info/CurrencyInfoContentV2.swift | 1 + .../Currency Info/CurrencyInfoScreen.swift | 1 + .../Currency Info/CurrencyInfoViewModel.swift | 1 + .../Core/Screens/Main/ExchangedBalance.swift | 1 + .../Screens/Main/Home/ActivityAvatar.swift | 1 + .../Main/Home/TransactionDetailsScreen.swift | 1 + .../Main/Operations/ScanCashOperation.swift | 1 + .../Main/Operations/SendCashOperation.swift | 1 + Flipcash/Core/Session/Session.swift | 1 + .../Core/Session/SessionAuthenticator.swift | 1 + Flipcash/Core/Session/SessionProtocols.swift | 1 + FlipcashCore/Package.swift | 18 + .../FlipcashStore}/Database+Activities.swift | 12 +- .../FlipcashStore}/Database+Balance.swift | 12 +- .../FlipcashStore}/Database+Blocklist.swift | 8 +- .../FlipcashStore}/Database+ContactSync.swift | 35 +- .../Database+Conversations.swift | 44 +- .../FlipcashStore}/Database+Limits.swift | 4 +- .../FlipcashStore}/Database+Onboarding.swift | 4 +- .../FlipcashStore}/Database+Profile.swift | 8 +- .../FlipcashStore}/Database+Rates.swift | 4 +- .../FlipcashStore}/Database+Singleton.swift | 8 +- .../Database+UserProfiles.swift | 6 +- .../Database+VerifiedProtos.swift | 0 .../Sources/FlipcashStore}/Database.swift | 29 +- .../FlipcashStore}/Models/StoredBalance.swift | 42 +- .../Models/StoredMintMetadata.swift | 58 +-- .../Sources/FlipcashStore}/Schema.swift | 396 ++++++++++-------- .../FlipcashStore}/StoreMigration.swift | 6 +- .../AddMoneyProcessingViewModelTests.swift | 1 + FlipcashTests/AddMoneyGateTests.swift | 1 + .../Buy/BuyConfirmationViewModelTests.swift | 1 + .../Chat/ConversationReceiptWiringTests.swift | 1 + .../Chat/ConversationReplySendTests.swift | 1 + .../Chat/MessageLoaderRevealTests.swift | 1 + .../ContactSyncControllerTests.swift | 1 + .../ConversationControllerTests.swift | 1 + FlipcashTests/ConversationMutationTests.swift | 1 + .../Convert/ConvertAmountViewModelTests.swift | 1 + ...rrencyPaymentSelectionViewModelTests.swift | 1 + .../CurrencyInfoViewModelTests.swift | 1 + .../Database+BalanceUpsertTests.swift | 1 + .../Database/Database+ContactSyncTests.swift | 1 + .../Database+ConversationsTests.swift | 1 + .../Database/Database+LiveSupplyTests.swift | 1 + .../Database/Database+MintUpsertTests.swift | 1 + .../Database/Database+OnboardingTests.swift | 1 + .../Database/Database+ProfileTests.swift | 1 + .../Database+VerifiedProtosTests.swift | 1 + FlipcashTests/DatabaseLifecycleTests.swift | 1 + FlipcashTests/HistoryControllerTests.swift | 1 + FlipcashTests/MessageLoaderTests.swift | 1 + FlipcashTests/RatesControllerTests.swift | 1 + .../Regressions/Regression_69ea049e.swift | 1 + .../Regressions/Regression_69ea28b0.swift | 1 + .../Regression_6a510fab7372e33c88417bd4.swift | 1 + .../Regression_sell_max_precision.swift | 1 + FlipcashTests/SessionTests.swift | 1 + FlipcashTests/StoreMigrationTests.swift | 1 + .../StoredBalanceAppreciationTests.swift | 1 + .../TestSupport/Database+TestSupport.swift | 1 + .../Database+VerifiedProtosTestSupport.swift | 1 + .../ExchangedBalance+TestSupport.swift | 1 + FlipcashTests/TestSupport/MockSession.swift | 1 + FlipcashTests/TestSupport/Mocks.swift | 1 + .../SessionContainer+TestSupport.swift | 1 + .../WithdrawViewModel+TestSupport.swift | 1 + 86 files changed, 457 insertions(+), 313 deletions(-) rename Flipcash/Core/Controllers/{Database => }/Updateable.swift (100%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Activities.swift (96%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Balance.swift (96%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Blocklist.swift (87%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+ContactSync.swift (80%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Conversations.swift (91%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Limits.swift (74%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Onboarding.swift (95%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Profile.swift (66%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Rates.swift (93%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Singleton.swift (82%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+UserProfiles.swift (85%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+VerifiedProtos.swift (100%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database.swift (88%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Models/StoredBalance.swift (79%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Models/StoredMintMetadata.swift (76%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Schema.swift (58%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/StoreMigration.swift (98%) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 3ea3ef5e7..faba12d69 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/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..43eab2c01 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") diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index ef5e9e434..b8539e6b7 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") 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/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 ed120ca9c..5aa9af056 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") 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/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/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/Flipcash/Core/Controllers/Database/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift similarity index 88% rename from Flipcash/Core/Controllers/Database/Database.swift rename to FlipcashCore/Sources/FlipcashStore/Database.swift index 90b154bf8..b95f6268f 100644 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -7,11 +7,11 @@ import Foundation import FlipcashCore -import SQLite +public import SQLite nonisolated private let logger = Logger(label: "flipcash.database") -typealias Expression = SQLite.Expression +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 @@ -21,7 +21,10 @@ typealias Expression = SQLite.Expression // 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. -nonisolated class Database: @unchecked 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 @@ -33,7 +36,7 @@ nonisolated class Database: @unchecked Sendable { private let lock = NSLock() /// The write connection, opening the store first if it is currently closed. - var writer: Connection { + public var writer: Connection { get throws { lock.lock() defer { lock.unlock() } @@ -49,7 +52,7 @@ nonisolated class Database: @unchecked Sendable { } /// The read connection, opening the store first if it is currently closed. - var reader: Connection { + public var reader: Connection { get throws { lock.lock() defer { lock.unlock() } @@ -66,7 +69,7 @@ nonisolated class Database: @unchecked Sendable { // MARK: - Init - - init(url: URL) throws { + public init(url: URL) throws { self.storeURL = url // Opening both here keeps an unusable store path failing at `init`, where it @@ -105,7 +108,7 @@ nonisolated class Database: @unchecked Sendable { /// 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 { + public func transaction(silent: Bool = false, _ block: (Database) throws -> Void) rethrows { do { let connection = try writer let startChangeCount = connection.totalChanges @@ -150,7 +153,7 @@ nonisolated class Database: @unchecked Sendable { /// 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 { + public func checkpoint() throws { try writer.run(Self.checkpointPragma) } @@ -164,7 +167,7 @@ nonisolated class Database: @unchecked Sendable { /// 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. - func close() throws { + public func close() throws { lock.lock() defer { _reader = nil @@ -184,7 +187,7 @@ nonisolated class Database: @unchecked Sendable { /// /// 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. - static func deleteStore(files: StoreLocation.Files) throws { + public static func deleteStore(files: StoreLocation.Files) throws { let urlsToRemove: [URL] = [ files.database, files.shm, @@ -198,7 +201,7 @@ nonisolated class Database: @unchecked Sendable { } } - static func setUserVersion(version: Int, files: StoreLocation.Files) throws { + public static func setUserVersion(version: Int, files: StoreLocation.Files) throws { try "\(version)".write( to: files.version, atomically: true, @@ -206,7 +209,7 @@ nonisolated class Database: @unchecked Sendable { ) } - static func userVersion(files: StoreLocation.Files) throws -> Int? { + public static func userVersion(files: StoreLocation.Files) throws -> Int? { let versionString = try String( contentsOf: files.version, encoding: .utf8 @@ -217,6 +220,6 @@ nonisolated class Database: @unchecked Sendable { } nonisolated extension Notification.Name { - static let databaseDidChange = Notification.Name("databaseDidChange") + public static let databaseDidChange = Notification.Name("databaseDidChange") } 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/Flipcash/Core/Controllers/Database/StoreMigration.swift b/FlipcashCore/Sources/FlipcashStore/StoreMigration.swift similarity index 98% rename from Flipcash/Core/Controllers/Database/StoreMigration.swift rename to FlipcashCore/Sources/FlipcashStore/StoreMigration.swift index 986d0bc09..fc53ff9f6 100644 --- a/Flipcash/Core/Controllers/Database/StoreMigration.swift +++ b/FlipcashCore/Sources/FlipcashStore/StoreMigration.swift @@ -16,9 +16,9 @@ nonisolated private let logger = Logger(label: "flipcash.database.migration") /// 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 enum StoreMigration { +nonisolated public enum StoreMigration { - enum Outcome: Equatable { + 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. @@ -34,7 +34,7 @@ nonisolated enum StoreMigration { /// /// 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. - static func migrateIfNeeded( + public static func migrateIfNeeded( owner: PublicKey, location: StoreLocation, fileManager: FileManager = .default 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..1262bca38 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 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..0ba419d99 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 diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 4d0792c74..5cf44da36 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 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 8b6562e6e..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") 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 0faa4064a..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 diff --git a/FlipcashTests/Database/Database+MintUpsertTests.swift b/FlipcashTests/Database/Database+MintUpsertTests.swift index 8202122e0..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 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 985614fee..afc1c69d4 100644 --- a/FlipcashTests/DatabaseLifecycleTests.swift +++ b/FlipcashTests/DatabaseLifecycleTests.swift @@ -4,6 +4,7 @@ // import Foundation +import FlipcashStore import Testing @testable import Flipcash 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/StoreMigrationTests.swift b/FlipcashTests/StoreMigrationTests.swift index 3a4ce53ea..97b82c367 100644 --- a/FlipcashTests/StoreMigrationTests.swift +++ b/FlipcashTests/StoreMigrationTests.swift @@ -7,6 +7,7 @@ import Foundation import Testing import SQLite import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Store migration") 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/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