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