From 8b700b422b6705fb6c9dc0a62e91c2d80155e8c7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 13:26:17 -0400 Subject: [PATCH 1/5] feat(database): open the store on demand, close it on background (#753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(database): set the busy timeout in seconds, not milliseconds SQLite.swift's `Connection.busyTimeout` is a `Double` of seconds that gets multiplied by 1000 on the way to `sqlite3_busy_timeout`, so `busyTimeout = 2000` asked both connections to block for 2000 seconds — 33 minutes — where the comment beside it said two. Nothing has hit that ceiling while one process owns the store. It stops being academic once the notification service extension opens the same file: a wait that long is indistinguishable from a hang, and the extension has about thirty seconds to live. * feat(database): open the store on demand and close it on request `Database` opened a reader and a writer in `init` and held both for the life of the process, with no way to give them back. `close()` supplies the other half: checkpoint the write-ahead log, drop both connections, and let the next `reader` or `writer` access reopen the store and reapply the pragmas. 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 another caller is still holding closes when that caller returns rather than here. That is also why nothing pairs with `close()`: a call that lands at an awkward moment costs a reopen instead of leaving a dead object behind. `reader` and `writer` become throwing computed properties. All 121 uses in `Database+*.swift` and `Schema.swift` already sat inside a `try` expression and did not change; the eight in tests reading `writer.totalChanges` did not, and now do. `journal_mode` lives in the database header, but `cache_size`, `foreign_keys` and the busy timeout are per-connection and are gone after a close, so `pragmasAreReappliedOnReopen` and `busyTimeoutIsTwoSeconds` assert them through a cycle rather than trusting the open path to have run. * feat(app): close the database when the app enters the background `scenePhaseChanged(.background)` now checkpoints and closes the store under a background-task assertion. `.active` has no counterpart on purpose: the connections reopen on the first read, so an app that never comes back costs nothing and a close that lands at an awkward moment repairs itself. The 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. --- Flipcash/Core/AppDelegate.swift | 33 +++++ .../Core/Controllers/Database/Database.swift | 118 +++++++++++++++--- .../Database+BalanceUpsertTests.swift | 16 +-- .../Database/Database+LiveSupplyTests.swift | 4 +- .../Database/Database+MintUpsertTests.swift | 12 +- FlipcashTests/DatabaseLifecycleTests.swift | 91 ++++++++++++++ 6 files changed, 240 insertions(+), 34 deletions(-) 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/Database/Database.swift b/Flipcash/Core/Controllers/Database/Database.swift index 2fcce265c..08075feb0 100644 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ b/Flipcash/Core/Controllers/Database/Database.swift @@ -18,31 +18,86 @@ typealias Expression = SQLite.Expression // 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. nonisolated class Database: @unchecked Sendable { - let reader: Connection - let writer: Connection - 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. + 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. + 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 - 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 - + + // 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 - @@ -52,11 +107,12 @@ nonisolated class Database: @unchecked Sendable { @inline(__always) func transaction(silent: Bool = false, _ block: (Database) throws -> Void) rethrows { do { - let startChangeCount = writer.totalChanges - try writer.transaction { [unowned self] in + let connection = try writer + let startChangeCount = connection.totalChanges + try connection.transaction { [unowned self] in try block(self) } - let endChangeCount = writer.totalChanges + let endChangeCount = connection.totalChanges // There are instances where we want to commit // the transaction but avoid notifying the UI @@ -87,13 +143,39 @@ nonisolated class Database: @unchecked Sendable { // 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. func checkpoint() throws { - try writer.run("PRAGMA wal_checkpoint(TRUNCATE);") + 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. + 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 - diff --git a/FlipcashTests/Database/Database+BalanceUpsertTests.swift b/FlipcashTests/Database/Database+BalanceUpsertTests.swift index 4c25bf2f3..8b6562e6e 100644 --- a/FlipcashTests/Database/Database+BalanceUpsertTests.swift +++ b/FlipcashTests/Database/Database+BalanceUpsertTests.swift @@ -19,9 +19,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 +33,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 +48,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 +59,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+LiveSupplyTests.swift b/FlipcashTests/Database/Database+LiveSupplyTests.swift index 7d3725b6b..0faa4064a 100644 --- a/FlipcashTests/Database/Database+LiveSupplyTests.swift +++ b/FlipcashTests/Database/Database+LiveSupplyTests.swift @@ -87,12 +87,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..8202122e0 100644 --- a/FlipcashTests/Database/Database+MintUpsertTests.swift +++ b/FlipcashTests/Database/Database+MintUpsertTests.swift @@ -146,9 +146,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 +162,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 +176,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/DatabaseLifecycleTests.swift b/FlipcashTests/DatabaseLifecycleTests.swift index 38aa73201..985614fee 100644 --- a/FlipcashTests/DatabaseLifecycleTests.swift +++ b/FlipcashTests/DatabaseLifecycleTests.swift @@ -59,4 +59,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) + } } From 2b924e5db50c9a0c52e71208f5cb425aee05896a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 13:26:27 -0400 Subject: [PATCH 2/5] feat(database): move the SQLite store into the App Group container (#754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add StoreLocation for the App Group store paths The store's four file names are currently built inline in four `URL` extensions on `applicationSupportDirectory`, which the notification extensions cannot read. StoreLocation names the same files against an injectable directory so the move to `group.com.flipcash.shared` becomes a change of directory rather than a rewrite of every path. It resolves the container itself and reports, via `isShared`, when that lookup failed and it fell back to the legacy directory. FlipcashCore has no reporting channel, so the flag exists for the caller to act on. The container lookup is injected. On iOS it returns nil for an unentitled group, but on macOS — where this package's tests run — it constructs a path for any identifier, so the fallback branch is unreachable from a test that just passes a bogus group name. * fix(database): throw instead of trapping when the version file cannot be written `setUserVersion` is declared `throws` but used `try!`, so a failed write terminated the process instead of reaching the caller. The one caller, `SessionAuthenticator.initializeDatabase`, already propagates. It has not fired because the destination is inside the app's own container and is created moments earlier by `createApplicationSupportIfNeeded`. The next commit moves that destination into the App Group container, where the write depends on a container the app does not create. * feat(database): move the store into the App Group container The notification service extension prefetches five messages on every push and writes them to a JSON side-car, because the SQLite store sits in the app's private Application Support directory and the extension is a separate process with a separate container. Moving the store into `group.com.flipcash.shared` is what gives that prefetch somewhere the app will look. `Database` no longer builds its own paths. The three static file operations take a `StoreLocation.Files`, and `SessionAuthenticator` resolves the location once per login and passes it down, so a migration cannot read one directory while the store opens from another. `StoreMigration` handles an install that already has a store in the old location. It checkpoints first, so what moves is a single file rather than a database and a log that can end up split across directories. It moves the version file before the database, which is the ordering that survives being interrupted: a next launch that finds no database at the destination retries and treats the already-moved version file as done, where the reverse order leaves a store with no recorded version, which reads as 0 and triggers a full rebuild. A move that fails removes only what it created, so a destination store that already held data is never cleared. When the container does not resolve, `StoreLocation.resolved()` falls back to Application Support and reports it. The app keeps working with an extension that cannot see the store, which is a provisioning problem rather than a reason to refuse login. * test(database): cover the move into the App Group container Twelve cases over `StoreMigration.migrateIfNeeded`, against seeded stores in temporary directories rather than a real container, so the App Group entitlement is not a precondition for running them. The interesting ones are the interrupted cases. `interruptedMoveResumes` seeds the state a launch that died between the version file and the database leaves behind, and asserts the next launch finishes the job. `destinationStoreWins` and `destinationVersionFileIsAuthoritative` cover the store that is already in the container, where the legacy files are leftovers to sweep, not data to adopt. `walContentsAreFoldedInBeforeTheMove` is what justifies checkpointing first: it puts a row in the log and nowhere else, and reads it back at the destination. Two of them go through `Database` rather than a raw `Connection`, which is what covers the ordering in `Database.init`. A migrated store arrives as a lone `.sqlite` — the log was folded in and the `-shm` swept — and a read-only connection to a WAL-mode database cannot create the `-shm` it needs, so it fails with `unable to open database file`. Opening the writer first is what creates it. --- .../Core/Controllers/Database/Database.swift | 42 +-- .../Controllers/Database/StoreMigration.swift | 150 +++++++++++ .../Core/Session/SessionAuthenticator.swift | 61 ++++- .../FlipcashCore/Storage/StoreLocation.swift | 122 +++++++++ .../StoreLocationTests.swift | 145 ++++++++++ FlipcashTests/StoreMigrationTests.swift | 250 ++++++++++++++++++ 6 files changed, 732 insertions(+), 38 deletions(-) create mode 100644 Flipcash/Core/Controllers/Database/StoreMigration.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift create mode 100644 FlipcashTests/StoreMigrationTests.swift diff --git a/Flipcash/Core/Controllers/Database/Database.swift b/Flipcash/Core/Controllers/Database/Database.swift index 08075feb0..90b154bf8 100644 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ b/Flipcash/Core/Controllers/Database/Database.swift @@ -179,12 +179,16 @@ nonisolated class Database: @unchecked Sendable { } // MARK: - Versioning - - - static func deleteStore(owner: PublicKey) throws { + + /// 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. + static func deleteStore(files: StoreLocation.Files) throws { let urlsToRemove: [URL] = [ - .dataStore(owner: owner), - .storeSHM(owner: owner), - .storeWAL(owner: owner), + files.database, + files.shm, + files.wal, ] try urlsToRemove.forEach { @@ -194,17 +198,17 @@ nonisolated class Database: @unchecked Sendable { } } - static func setUserVersion(version: Int, owner: PublicKey) throws { - try! "\(version)".write( - to: .versionFile(owner: owner), + static func setUserVersion(version: Int, files: StoreLocation.Files) throws { + try "\(version)".write( + to: files.version, atomically: true, encoding: .utf8 ) } - static func userVersion(owner: PublicKey) throws -> Int? { + static func userVersion(files: StoreLocation.Files) throws -> Int? { let versionString = try String( - contentsOf: .versionFile(owner: owner), + contentsOf: files.version, encoding: .utf8 ) @@ -212,24 +216,6 @@ nonisolated class Database: @unchecked Sendable { } } -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/Database/StoreMigration.swift b/Flipcash/Core/Controllers/Database/StoreMigration.swift new file mode 100644 index 000000000..986d0bc09 --- /dev/null +++ b/Flipcash/Core/Controllers/Database/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 enum StoreMigration { + + 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. + 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/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 17114cb37..ed120ca9c 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -293,30 +293,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 userVersion = (try? Database.userVersion(files: files)) ?? 0 let currentVersion = try InfoPlist.value(for: "SQLiteVersion").integer() 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/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/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/StoreMigrationTests.swift b/FlipcashTests/StoreMigrationTests.swift new file mode 100644 index 000000000..3a4ce53ea --- /dev/null +++ b/FlipcashTests/StoreMigrationTests.swift @@ -0,0 +1,250 @@ +// +// StoreMigrationTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SQLite +import FlipcashCore +@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)) + } +} + From 788051bd64efead6128b51270cdfbb9bbbc5036a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 13:26:38 -0400 Subject: [PATCH 3/5] refactor(store): move the persistence layer into a shared FlipcashStore package (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification service extension cannot reach `Database` while it lives in the app target. Xcode's synchronised file groups do not offer a way in: all three `PBXFileSystemSynchronizedBuildFileExceptionSet` entries in this project list `Info.plist` as an exclusion, which is the only thing the mechanism does. A file cannot join a second target that way, so sharing code with the extension means a package. `Flipcash/Core/Controllers/Database/` becomes `FlipcashStore`, a new target in `FlipcashCore` linked into both `Flipcash` and `NotificationService`. It is separate from `FlipcashCore` rather than part of it so that everything depending on the models does not also pull in SQLite. The SQLite.swift dependency is branch-pinned to match the app project's reference to the same fork, because SPM resolves one version of it for the whole graph. The move is mechanical: types the app target already used become `public`, and `import FlipcashStore` is added to the 28 app files and 38 test files that read through the database. Two things did not move cleanly: - `Updateable` stayed in the app target. It is a SwiftUI `@Observable` wrapper that re-queries on `.databaseDidChange`, not persistence. The app target defaults new types to `@MainActor` and a package target does not, so in the package its `init` could not send `self` into a `@MainActor` task. - `Database` is `open` rather than `public`, so the test bundle's `TempDatabase` can keep tying temp-file cleanup to the database's lifetime. Its members stay `public`: a subclass can add state but cannot override behaviour. No behaviour change. The database tests stay in the app test bundle so they keep running on the simulator — the read-only-WAL and same-inode failures this store hits are iOS-specific and do not reproduce on macOS. --- Code.xcodeproj/project.pbxproj | 10 + .../Controllers/BlocklistController.swift | 1 + .../Core/Controllers/ContactDirectory.swift | 1 + .../Controllers/ContactSyncController.swift | 1 + .../Controllers/ConversationController.swift | 1 + .../Core/Controllers/HistoryController.swift | 1 + .../Core/Controllers/RatesController.swift | 1 + .../{Database => }/Updateable.swift | 0 .../Screens/Main/AddMoney/AddMoneyGate.swift | 1 + .../AddMoneyProcessingViewModel.swift | 1 + .../Screens/Main/Bill/BillValuation.swift | 1 + .../Screens/Main/Buy/BuyAmountViewModel.swift | 1 + .../Main/Buy/BuyConfirmationScreen.swift | 1 + .../Main/Buy/BuyConfirmationViewModel.swift | 1 + .../Core/Screens/Main/Buy/BuyFlowPath.swift | 1 + .../ConvertAmountScreen.swift | 1 + .../ConvertAmountViewModel.swift | 1 + .../CurrencyCreationWizardScreen.swift | 1 + .../CurrencyPaymentSelectionViewModel.swift | 1 + .../Currency Info/CurrencyInfoContentV2.swift | 1 + .../Currency Info/CurrencyInfoScreen.swift | 1 + .../Currency Info/CurrencyInfoViewModel.swift | 1 + .../Core/Screens/Main/ExchangedBalance.swift | 1 + .../Screens/Main/Home/ActivityAvatar.swift | 1 + .../Main/Home/TransactionDetailsScreen.swift | 1 + .../Main/Operations/ScanCashOperation.swift | 1 + .../Main/Operations/SendCashOperation.swift | 1 + Flipcash/Core/Session/Session.swift | 1 + .../Core/Session/SessionAuthenticator.swift | 1 + Flipcash/Core/Session/SessionProtocols.swift | 1 + FlipcashCore/Package.swift | 18 + .../FlipcashStore}/Database+Activities.swift | 12 +- .../FlipcashStore}/Database+Balance.swift | 12 +- .../FlipcashStore}/Database+Blocklist.swift | 8 +- .../FlipcashStore}/Database+ContactSync.swift | 35 +- .../Database+Conversations.swift | 44 +- .../FlipcashStore}/Database+Limits.swift | 4 +- .../FlipcashStore}/Database+Onboarding.swift | 4 +- .../FlipcashStore}/Database+Profile.swift | 8 +- .../FlipcashStore}/Database+Rates.swift | 4 +- .../FlipcashStore}/Database+Singleton.swift | 8 +- .../Database+UserProfiles.swift | 6 +- .../Database+VerifiedProtos.swift | 0 .../Sources/FlipcashStore}/Database.swift | 29 +- .../FlipcashStore}/Models/StoredBalance.swift | 42 +- .../Models/StoredMintMetadata.swift | 58 +-- .../Sources/FlipcashStore}/Schema.swift | 396 ++++++++++-------- .../FlipcashStore}/StoreMigration.swift | 6 +- .../AddMoneyProcessingViewModelTests.swift | 1 + FlipcashTests/AddMoneyGateTests.swift | 1 + .../Buy/BuyConfirmationViewModelTests.swift | 1 + .../Chat/ConversationReceiptWiringTests.swift | 1 + .../Chat/ConversationReplySendTests.swift | 1 + .../Chat/MessageLoaderRevealTests.swift | 1 + .../ContactSyncControllerTests.swift | 1 + .../ConversationControllerTests.swift | 1 + FlipcashTests/ConversationMutationTests.swift | 1 + .../Convert/ConvertAmountViewModelTests.swift | 1 + ...rrencyPaymentSelectionViewModelTests.swift | 1 + .../CurrencyInfoViewModelTests.swift | 1 + .../Database+BalanceUpsertTests.swift | 1 + .../Database/Database+ContactSyncTests.swift | 1 + .../Database+ConversationsTests.swift | 1 + .../Database/Database+LiveSupplyTests.swift | 1 + .../Database/Database+MintUpsertTests.swift | 1 + .../Database/Database+OnboardingTests.swift | 1 + .../Database/Database+ProfileTests.swift | 1 + .../Database+VerifiedProtosTests.swift | 1 + FlipcashTests/DatabaseLifecycleTests.swift | 1 + FlipcashTests/HistoryControllerTests.swift | 1 + FlipcashTests/MessageLoaderTests.swift | 1 + FlipcashTests/RatesControllerTests.swift | 1 + .../Regressions/Regression_69ea049e.swift | 1 + .../Regressions/Regression_69ea28b0.swift | 1 + .../Regression_6a510fab7372e33c88417bd4.swift | 1 + .../Regression_sell_max_precision.swift | 1 + FlipcashTests/SessionTests.swift | 1 + FlipcashTests/StoreMigrationTests.swift | 1 + .../StoredBalanceAppreciationTests.swift | 1 + .../TestSupport/Database+TestSupport.swift | 1 + .../Database+VerifiedProtosTestSupport.swift | 1 + .../ExchangedBalance+TestSupport.swift | 1 + FlipcashTests/TestSupport/MockSession.swift | 1 + FlipcashTests/TestSupport/Mocks.swift | 1 + .../SessionContainer+TestSupport.swift | 1 + .../WithdrawViewModel+TestSupport.swift | 1 + 86 files changed, 457 insertions(+), 313 deletions(-) rename Flipcash/Core/Controllers/{Database => }/Updateable.swift (100%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Activities.swift (96%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Balance.swift (96%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Blocklist.swift (87%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+ContactSync.swift (80%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Conversations.swift (91%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Limits.swift (74%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Onboarding.swift (95%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Profile.swift (66%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Rates.swift (93%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+Singleton.swift (82%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+UserProfiles.swift (85%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database+VerifiedProtos.swift (100%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Database.swift (88%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Models/StoredBalance.swift (79%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Models/StoredMintMetadata.swift (76%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/Schema.swift (58%) rename {Flipcash/Core/Controllers/Database => FlipcashCore/Sources/FlipcashStore}/StoreMigration.swift (98%) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 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/Flipcash/Core/Controllers/BlocklistController.swift b/Flipcash/Core/Controllers/BlocklistController.swift index 44df812a2..67a36c109 100644 --- a/Flipcash/Core/Controllers/BlocklistController.swift +++ b/Flipcash/Core/Controllers/BlocklistController.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.blocklist-controller") diff --git a/Flipcash/Core/Controllers/ContactDirectory.swift b/Flipcash/Core/Controllers/ContactDirectory.swift index ea673b27a..114d553db 100644 --- a/Flipcash/Core/Controllers/ContactDirectory.swift +++ b/Flipcash/Core/Controllers/ContactDirectory.swift @@ -6,6 +6,7 @@ import Contacts import Foundation import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.contact-directory") diff --git a/Flipcash/Core/Controllers/ContactSyncController.swift b/Flipcash/Core/Controllers/ContactSyncController.swift index 2247d73ac..43eab2c01 100644 --- a/Flipcash/Core/Controllers/ContactSyncController.swift +++ b/Flipcash/Core/Controllers/ContactSyncController.swift @@ -6,6 +6,7 @@ import Contacts import Foundation import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.contact-sync-controller") diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index ef5e9e434..b8539e6b7 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.conversation-controller") diff --git a/Flipcash/Core/Controllers/HistoryController.swift b/Flipcash/Core/Controllers/HistoryController.swift index 44003a766..41016d306 100644 --- a/Flipcash/Core/Controllers/HistoryController.swift +++ b/Flipcash/Core/Controllers/HistoryController.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.history-controller") diff --git a/Flipcash/Core/Controllers/RatesController.swift b/Flipcash/Core/Controllers/RatesController.swift index fe1a85f41..dd71ecfd2 100644 --- a/Flipcash/Core/Controllers/RatesController.swift +++ b/Flipcash/Core/Controllers/RatesController.swift @@ -14,6 +14,7 @@ import Foundation // or these are migrated to AsyncSequence. @preconcurrency import Combine import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.rates-controller") diff --git a/Flipcash/Core/Controllers/Database/Updateable.swift b/Flipcash/Core/Controllers/Updateable.swift similarity index 100% rename from Flipcash/Core/Controllers/Database/Updateable.swift rename to Flipcash/Core/Controllers/Updateable.swift diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift index 62cb88c57..420556543 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift @@ -5,6 +5,7 @@ import Foundation import FlipcashCore +import FlipcashStore /// Read access to every balance the launch gate weighs. @MainActor diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift index 4d90bb41b..8d81860a6 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyProcessingViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.add-money-processing") diff --git a/Flipcash/Core/Screens/Main/Bill/BillValuation.swift b/Flipcash/Core/Screens/Main/Bill/BillValuation.swift index a0e13ca9a..1e00da359 100644 --- a/Flipcash/Core/Screens/Main/Bill/BillValuation.swift +++ b/Flipcash/Core/Screens/Main/Bill/BillValuation.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore struct BillValuation: Identifiable { diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 11711f357..7084c788e 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.buy-amount") diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift index cd501acdc..751eabdfa 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationScreen.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI struct BuyConfirmationScreen: View { diff --git a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift index 5f99c1e2a..593376298 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyConfirmationViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.buy-confirmation") diff --git a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift index d89747012..1d3544102 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyFlowPath.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore /// Sub-flow path for the buy stack. The `.buy(mint)` sheet's root is /// `BuyAmountScreen`; secondary screens (buy summary, post-buy processing) are diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift index e4319fc8d..65ac25012 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI /// Amount entry for converting a currency into a chosen destination. Pushed diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift index 582658861..1e2cb0623 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.convert-amount") diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index f05337781..434ac1bc3 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -6,6 +6,7 @@ import SwiftUI import UniformTypeIdentifiers import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.currency-creation") diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift index effbc0b82..ff5f6b1c6 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyPaymentSelectionViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI @Observable diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index ce2e1fa6e..25cd995d2 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -10,6 +10,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI /// Marks the hero card as the morph destination for the wallet's tapped card. diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index c849ab61d..07c65b23e 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -8,6 +8,7 @@ import SwiftUI import FlipcashUI import FlipcashCore +import FlipcashStore /// Thin environment-reading wrapper that hands the DI containers to /// ``CurrencyInfoScreenContent``, whose two-init delegation builds the `@State` diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index e1ebd180e..3e757abe4 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore @Observable class CurrencyInfoViewModel { diff --git a/Flipcash/Core/Screens/Main/ExchangedBalance.swift b/Flipcash/Core/Screens/Main/ExchangedBalance.swift index 388208ed2..f4e75ad37 100644 --- a/Flipcash/Core/Screens/Main/ExchangedBalance.swift +++ b/Flipcash/Core/Screens/Main/ExchangedBalance.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore /// A stored balance paired with its fiat value at a given rate. struct ExchangedBalance: Identifiable, Hashable { diff --git a/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift b/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift index e84a85066..f479d994d 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift @@ -6,6 +6,7 @@ import SwiftUI import FlipcashUI import FlipcashCore +import FlipcashStore /// The avatar an activity draws — the counterparty's profile photo for peer /// activity (tips/sends), the token image for token activity (deposits, buys), diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index ffd45b6ef..cfaad557d 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -7,6 +7,7 @@ import SwiftUI import UIKit import FlipcashUI import FlipcashCore +import FlipcashStore /// One activity entry in full (Figma node 9708:105260) — what opens when a row is /// tapped in the Wallet's Recent section, the cross-token history, or a token's diff --git a/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift b/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift index f472873c5..d57f194a9 100644 --- a/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift +++ b/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.scan-cash") diff --git a/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift b/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift index be9b0b5bd..5919f0846 100644 --- a/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift +++ b/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.send-cash") diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index b4beaa2b3..211262f1d 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -8,6 +8,7 @@ import UIKit import FlipcashUI import FlipcashCore +import FlipcashStore private let logger = Logger(label: "flipcash.session") diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index ed120ca9c..5aa9af056 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashStore import FlipcashUI private let logger = Logger(label: "flipcash.session-auth") diff --git a/Flipcash/Core/Session/SessionProtocols.swift b/Flipcash/Core/Session/SessionProtocols.swift index 01e453deb..5968d3a88 100644 --- a/Flipcash/Core/Session/SessionProtocols.swift +++ b/Flipcash/Core/Session/SessionProtocols.swift @@ -5,6 +5,7 @@ import Foundation import FlipcashCore +import FlipcashStore // MARK: - Account diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index a983cc036..f7c3b9d49 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -14,6 +14,10 @@ let package = Package( name: "FlipcashCore", targets: ["FlipcashCore"] ), + .library( + name: "FlipcashStore", + targets: ["FlipcashStore"] + ), ], dependencies: [ .package(url: "https://github.com/marmelroy/PhoneNumberKit", from: "4.1.4"), @@ -24,6 +28,9 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), .package(path: "../FlipcashAPI"), .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.5.0")), + // Branch-pinned to match the app project's own reference to the same fork. SPM resolves one + // version of it for the whole graph, so the two have to agree. + .package(url: "https://github.com/dbart01/SQLite.swift", branch: "master"), ], targets: [ .target( @@ -43,6 +50,17 @@ let package = Package( .copy("Resources/discrete_cumulative_table.bin"), ] ), + // The SQLite store, shared by the app and the notification service extension. It is a + // separate target rather than part of `FlipcashCore` so that everything depending on the + // models does not also pull in SQLite. + .target( + name: "FlipcashStore", + dependencies: [ + "FlipcashCore", + .product(name: "Logging", package: "swift-log"), + .product(name: "SQLite", package: "SQLite.swift"), + ] + ), .testTarget( name: "FlipcashCoreTests", dependencies: [ diff --git a/Flipcash/Core/Controllers/Database/Database+Activities.swift b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift similarity index 96% rename from Flipcash/Core/Controllers/Database/Database+Activities.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Activities.swift index 179ecaca9..625962d0d 100644 --- a/Flipcash/Core/Controllers/Database/Database+Activities.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift @@ -13,7 +13,7 @@ nonisolated extension Database { // MARK: - Get - - func getLatestActivityID() throws -> PublicKey? { + public func getLatestActivityID() throws -> PublicKey? { let statement = try reader.prepareRowIterator(""" SELECT a.id @@ -32,7 +32,7 @@ nonisolated extension Database { return ids.first } - func getPendingActivityIDs() throws -> [PublicKey] { + public func getPendingActivityIDs() throws -> [PublicKey] { let statement = try reader.prepareRowIterator(""" SELECT a.id @@ -55,7 +55,7 @@ nonisolated extension Database { /// serialises access through its own dispatch queue; this wrapper only /// hops off main so the caller's actor isn't blocked on up-to-1024 /// `NSDateFormatter.dateFromString(_:)` calls. - func getActivities(mint: PublicKey) async throws -> [Activity] { + public func getActivities(mint: PublicKey) async throws -> [Activity] { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { @@ -68,7 +68,7 @@ nonisolated extension Database { } } - func getActivities(mint: PublicKey) throws -> [Activity] { + public func getActivities(mint: PublicKey) throws -> [Activity] { let statement = try reader.prepareRowIterator(""" SELECT a.id, @@ -115,7 +115,7 @@ nonisolated extension Database { /// The unified, cross-mint recent activity for the wallet preview: the newest /// `limit` activities regardless of token. `getActivities(mint:)` is the /// per-token slice; this is the "everything" feed. - func getRecentActivities(limit: Int) throws -> [Activity] { + public func getRecentActivities(limit: Int) throws -> [Activity] { let statement = try reader.prepareRowIterator(""" SELECT a.id, @@ -250,7 +250,7 @@ nonisolated extension Database { // MARK: - Insert - - func insertActivities(activities: [Activity]) throws { + public func insertActivities(activities: [Activity]) throws { try activities.forEach { try insertActivity(activity: $0) } diff --git a/Flipcash/Core/Controllers/Database/Database+Balance.swift b/FlipcashCore/Sources/FlipcashStore/Database+Balance.swift similarity index 96% rename from Flipcash/Core/Controllers/Database/Database+Balance.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Balance.swift index 1a262f97a..c0316cf3c 100644 --- a/Flipcash/Core/Controllers/Database/Database+Balance.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Balance.swift @@ -15,7 +15,7 @@ nonisolated extension Database { // MARK: - Get - - func getBalances() throws -> [StoredBalance] { + public func getBalances() throws -> [StoredBalance] { let statement = try reader.prepareRowIterator(""" SELECT b.quarks, @@ -58,7 +58,7 @@ nonisolated extension Database { return balances } - func getMintMetadata(mint: PublicKey) throws -> StoredMintMetadata? { + public func getMintMetadata(mint: PublicKey) throws -> StoredMintMetadata? { let stored = try fetchStoredMint(mint) if stored == nil { logger.warning("Missing mint in database", metadata: ["mint": "\(mint.base58)"]) @@ -132,7 +132,7 @@ nonisolated extension Database { return mints.first } - func getVMAuthority(mint: PublicKey) throws -> PublicKey? { + public func getVMAuthority(mint: PublicKey) throws -> PublicKey? { let statement = try reader.prepareRowIterator(""" SELECT m.vmAuthority @@ -154,7 +154,7 @@ nonisolated extension Database { // MARK: - Live Supply - - func updateLiveSupply(updates: [ReserveStateUpdate], date: Date) throws { + public func updateLiveSupply(updates: [ReserveStateUpdate], date: Date) throws { try transaction { let table = MintTable() for update in updates { @@ -178,7 +178,7 @@ nonisolated extension Database { // MARK: - Insert - - func insertBalance(quarks: UInt64, mint: PublicKey, costBasis: Double, date: Date) throws { + public func insertBalance(quarks: UInt64, mint: PublicKey, costBasis: Double, date: Date) throws { let table = BalanceTable() // The filter becomes the DO UPDATE's WHERE clause (fork behavior — // see "SQLite.swift Fork" in CLAUDE.md): a conflicting row only @@ -199,7 +199,7 @@ nonisolated extension Database { ) } - func insert(mints: [MintMetadata], date: Date) throws { + public func insert(mints: [MintMetadata], date: Date) throws { try transaction { for mint in mints { try $0.insert(mint: mint, date: date) diff --git a/Flipcash/Core/Controllers/Database/Database+Blocklist.swift b/FlipcashCore/Sources/FlipcashStore/Database+Blocklist.swift similarity index 87% rename from Flipcash/Core/Controllers/Database/Database+Blocklist.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Blocklist.swift index 21a558c16..97a61db05 100644 --- a/Flipcash/Core/Controllers/Database/Database+Blocklist.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Blocklist.swift @@ -12,7 +12,7 @@ import SQLite nonisolated extension Database { /// The cached blocklist, most-recently-blocked first. - func getBlockedUsers() throws -> [BlockedUserProfile] { + public func getBlockedUsers() throws -> [BlockedUserProfile] { let b = BlocklistTable() let rows = try reader.prepareRowIterator(b.table.order(b.blockedAt.desc)) return try rows.map { row in @@ -26,7 +26,7 @@ nonisolated extension Database { } /// Atomically replace the entire cached blocklist with `users`. - func replaceBlocklist(_ users: [BlockedUserProfile]) throws { + public func replaceBlocklist(_ users: [BlockedUserProfile]) throws { let b = BlocklistTable() try writer.transaction { try writer.run(b.table.delete()) @@ -42,7 +42,7 @@ nonisolated extension Database { } /// Insert or replace one blocked user (optimistic block). - func upsertBlockedUser(_ user: BlockedUserProfile) throws { + public func upsertBlockedUser(_ user: BlockedUserProfile) throws { let b = BlocklistTable() try writer.run(b.table.upsert( b.userID <- user.userID, @@ -54,7 +54,7 @@ nonisolated extension Database { } /// Remove one blocked user (optimistic unblock). - func deleteBlockedUser(userID: UserID) throws { + public func deleteBlockedUser(userID: UserID) throws { let b = BlocklistTable() try writer.run(b.table.filter(b.userID == userID).delete()) } diff --git a/Flipcash/Core/Controllers/Database/Database+ContactSync.swift b/FlipcashCore/Sources/FlipcashStore/Database+ContactSync.swift similarity index 80% rename from Flipcash/Core/Controllers/Database/Database+ContactSync.swift rename to FlipcashCore/Sources/FlipcashStore/Database+ContactSync.swift index a94d67a2e..74663170e 100644 --- a/Flipcash/Core/Controllers/Database/Database+ContactSync.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+ContactSync.swift @@ -13,13 +13,17 @@ nonisolated extension Database { /// The contact-sync state machine's persisted cursor. /// A `nil` checksum indicates first-run state. - struct ContactSyncState: Equatable, Sendable { - let checksum: Data? + public struct ContactSyncState: Equatable, Sendable { + public let checksum: Data? - static let empty = ContactSyncState(checksum: nil) + public init(checksum: Data?) { + self.checksum = checksum + } + + public static let empty = ContactSyncState(checksum: nil) } - func contactSyncState() throws -> ContactSyncState { + public func contactSyncState() throws -> ContactSyncState { let table = ContactSyncStateTable() guard let row = try reader.pluck(table.table.filter(table.id == 1)) else { return .empty @@ -27,7 +31,7 @@ nonisolated extension Database { return ContactSyncState(checksum: row[table.checksum]) } - func setContactSyncState(_ state: ContactSyncState) throws { + public func setContactSyncState(_ state: ContactSyncState) throws { let table = ContactSyncStateTable() try writer.transaction { try writer.run( @@ -43,7 +47,7 @@ nonisolated extension Database { // MARK: - Flipcash Contacts - /// Contacts the server has confirmed are on Flipcash, with their DM chat IDs. - func flipcashContacts() throws -> [MatchedContact] { + public func flipcashContacts() throws -> [MatchedContact] { let table = FlipcashContactTable() let rows = try reader.prepareRowIterator(table.table.select(table.e164, table.dmChatId, table.joinTs)) return try rows.map { MatchedContact(e164: $0[table.e164], dmChatID: $0[table.dmChatId], joinDate: $0[table.joinTs]) } @@ -54,7 +58,7 @@ nonisolated extension Database { /// Atomic — readers observe either the old set or the new set, never a partial join. /// Deduplicates on `e164` defensively in case the server ever streams the same number twice. @discardableResult - func replaceFlipcashContacts(_ contacts: [MatchedContact], matchedAt: Date) throws -> Int { + public func replaceFlipcashContacts(_ contacts: [MatchedContact], matchedAt: Date) throws -> Int { let table = FlipcashContactTable() var seen: Set = [] let deduped = contacts.filter { seen.insert($0.e164).inserted } @@ -78,12 +82,17 @@ nonisolated extension Database { /// One row per phone in the last successfully-uploaded contact set. /// `contactId` is `CNContact.identifier` for resolving name/avatar at render time. - struct LocalContact: Equatable, Hashable, Sendable { - let e164: String - let contactId: String + public struct LocalContact: Equatable, Hashable, Sendable { + public let e164: String + public let contactId: String + + public init(e164: String, contactId: String) { + self.e164 = e164 + self.contactId = contactId + } } - func localContactsSnapshot() throws -> [LocalContact] { + public func localContactsSnapshot() throws -> [LocalContact] { let table = LocalContactsSnapshotTable() let rows = try reader.prepareRowIterator(table.table) return try rows.map { row in @@ -92,7 +101,7 @@ nonisolated extension Database { } /// Replace the snapshot with the latest uploaded set. - func replaceLocalContactsSnapshot(_ contacts: [LocalContact]) throws { + public func replaceLocalContactsSnapshot(_ contacts: [LocalContact]) throws { try writer.transaction { try rewriteLocalContactsSnapshot(contacts) } @@ -120,7 +129,7 @@ nonisolated extension Database { // MARK: - Combined writes - /// Replace the snapshot AND upsert the sync state in one transaction. - func updateContactSyncSnapshotAndState( + public func updateContactSyncSnapshotAndState( snapshot contacts: [LocalContact], state: ContactSyncState ) throws { diff --git a/Flipcash/Core/Controllers/Database/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift similarity index 91% rename from Flipcash/Core/Controllers/Database/Database+Conversations.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 42a5bfce6..0f35e6ffa 100644 --- a/Flipcash/Core/Controllers/Database/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -15,7 +15,7 @@ nonisolated extension Database { /// Async wrapper that runs the synchronous cache reads off the caller's /// actor so session start never blocks the main thread on row decoding. - func loadConversationCache() async throws -> (conversations: [Conversation], cursors: [ConversationID: UInt64]) { + public func loadConversationCache() async throws -> (conversations: [Conversation], cursors: [ConversationID: UInt64]) { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { @@ -31,7 +31,7 @@ nonisolated extension Database { /// The persisted per-conversation event-log catch-up frontier (`GetDelta.after_sequence`), omitting /// conversations with no cursor yet. - func getCatchupCursors() throws -> [ConversationID: UInt64] { + public func getCatchupCursors() throws -> [ConversationID: UInt64] { let c = ConversationTable() let rows = try reader.prepareRowIterator(c.table).map { row in (id: ConversationID(data: row[c.id]), cursor: row[c.catchupCursor]) @@ -43,14 +43,14 @@ nonisolated extension Database { /// The persisted catch-up cursor for one conversation (0 when none) — used to re-seat the in-memory /// cursor after a failed message persist so recovery refetches, rather than skips, the window. - func catchupCursor(conversationID: ConversationID) throws -> UInt64 { + public func catchupCursor(conversationID: ConversationID) throws -> UInt64 { let c = ConversationTable() return (try reader.pluck(c.table.filter(c.id == conversationID.data))).flatMap { $0[c.catchupCursor] } ?? 0 } /// The cached DM feed, most-recent activity first, with members and the /// newest stored message as the `lastMessage` preview. - func getConversations() throws -> [Conversation] { + public func getConversations() throws -> [Conversation] { let c = ConversationTable() let m = ConversationMemberTable() @@ -91,7 +91,7 @@ nonisolated extension Database { /// The newest stored non-deleted message for a conversation, or nil when none is cached. Tombstones /// (`kind == 2`) are skipped so the feed preview shows the newest *visible* message rather than a /// blank row for a deleted last message. - func latestMessage(conversationID: ConversationID) throws -> ConversationMessage? { + public func latestMessage(conversationID: ConversationID) throws -> ConversationMessage? { try latestMessage(conversationId: conversationID.data) } @@ -106,7 +106,7 @@ nonisolated extension Database { } /// The newest stored message id (tombstones included) — the mark-read / receive-buzz anchor. - func newestMessageID(conversationID: ConversationID) throws -> MessageID? { + public func newestMessageID(conversationID: ConversationID) throws -> MessageID? { let m = ConversationMessageTable() return try reader.pluck( m.table.filter(m.conversationId == conversationID.data).order(m.id.desc) @@ -116,7 +116,7 @@ nonisolated extension Database { /// The newest stored message (tombstones included). Unlike ``latestMessage(conversationID:)``, a /// delete of the newest message does not regress this value to the previous row — it returns the /// tombstone itself — so identity-keyed triggers (receive buzz, mark-read) don't misfire on deletes. - func newestMessage(conversationID: ConversationID) throws -> ConversationMessage? { + public func newestMessage(conversationID: ConversationID) throws -> ConversationMessage? { let m = ConversationMessageTable() guard let row = try reader.pluck( m.table.filter(m.conversationId == conversationID.data).order(m.id.desc) @@ -128,7 +128,7 @@ nonisolated extension Database { /// Whether a specific message id is already persisted — the "is this a fresh echo?" gate for the /// optimistic-send reconcile, replacing the in-memory existence check. - func messageExists(id: MessageID, conversationID: ConversationID) throws -> Bool { + public func messageExists(id: MessageID, conversationID: ConversationID) throws -> Bool { let m = ConversationMessageTable() return try reader.scalar(m.table.filter(m.conversationId == conversationID.data && m.id == id.value).count) > 0 } @@ -136,7 +136,7 @@ nonisolated extension Database { /// The stored copy of one message, or `nil` if it is not in the local database. Mutations read /// through this rather than the display window, because `expected_event_sequence` must come /// from server truth, never from an optimistic overlay. - func message(id: MessageID, conversationID: ConversationID) throws -> ConversationMessage? { + public func message(id: MessageID, conversationID: ConversationID) throws -> ConversationMessage? { let m = ConversationMessageTable() let query = m.table.filter(m.conversationId == conversationID.data && m.id == id.value).limit(1) @@ -145,7 +145,7 @@ nonisolated extension Database { } /// All cached messages for a conversation, oldest first. - func getConversationMessages(conversationID: ConversationID) throws -> [ConversationMessage] { + public func getConversationMessages(conversationID: ConversationID) throws -> [ConversationMessage] { let m = ConversationMessageTable() let rows = try reader.prepareRowIterator( m.table.filter(m.conversationId == conversationID.data).order(m.id.asc) @@ -156,7 +156,7 @@ nonisolated extension Database { /// A bounded window of a conversation's messages, oldest-first: the newest `limit` when `before` is /// nil, otherwise the `limit` messages immediately older than `before`. Index-backed by the /// composite `(conversationId, id)` primary key — no scan, no sort. - func messagesWindow(conversationID: ConversationID, before: MessageID? = nil, limit: Int) throws -> [ConversationMessage] { + public func messagesWindow(conversationID: ConversationID, before: MessageID? = nil, limit: Int) throws -> [ConversationMessage] { let m = ConversationMessageTable() var query = m.table.filter(m.conversationId == conversationID.data) if let before { @@ -169,7 +169,7 @@ nonisolated extension Database { /// Every message from `startID` (inclusive) to the newest, oldest-first — the id-anchored window. /// Anchoring by id means an arriving message grows the window at the tail instead of sliding the /// oldest revealed row out from under a reader who has scrolled up. - func messages(conversationID: ConversationID, from startID: UInt64) throws -> [ConversationMessage] { + public func messages(conversationID: ConversationID, from startID: UInt64) throws -> [ConversationMessage] { let m = ConversationMessageTable() let rows = try reader.prepareRowIterator( m.table.filter(m.conversationId == conversationID.data && m.id >= startID).order(m.id.asc) @@ -181,7 +181,7 @@ nonisolated extension Database { /// read-pointer advance just crossed. A nil `after` means the pointer had never been set, so the /// whole stored history up to `through` counts as newly read. Index-backed by the composite /// `(conversationId, id)` primary key. - func messages(conversationID: ConversationID, after: MessageID?, through: MessageID) throws -> [ConversationMessage] { + public func messages(conversationID: ConversationID, after: MessageID?, through: MessageID) throws -> [ConversationMessage] { let m = ConversationMessageTable() var query = m.table.filter(m.conversationId == conversationID.data && m.id <= through.value) if let after { @@ -193,7 +193,7 @@ nonisolated extension Database { /// The id `step` rows older than `before` — the next anchor when the reader pages back — falling /// back to the oldest available older row; nil when nothing older is persisted. - func olderAnchor(conversationID: ConversationID, before: UInt64, step: Int) throws -> UInt64? { + public func olderAnchor(conversationID: ConversationID, before: UInt64, step: Int) throws -> UInt64? { let m = ConversationMessageTable() let older = m.table.filter(m.conversationId == conversationID.data && m.id < before) if let row = try reader.pluck(older.order(m.id.desc).limit(1, offset: step - 1)) { @@ -204,14 +204,14 @@ nonisolated extension Database { /// The number of confirmed messages persisted for a conversation — the ceiling the transcript /// window can grow to before older history must be paged from the server. - func messageCount(conversationID: ConversationID) throws -> Int { + public func messageCount(conversationID: ConversationID) throws -> Int { let m = ConversationMessageTable() return try reader.scalar(m.table.filter(m.conversationId == conversationID.data).count) } /// The oldest persisted message id for a conversation, or nil when none is cached — the anchor for /// paging genuinely older history from the server. - func oldestMessageID(conversationID: ConversationID) throws -> MessageID? { + public func oldestMessageID(conversationID: ConversationID) throws -> MessageID? { let m = ConversationMessageTable() return try reader.pluck( m.table.filter(m.conversationId == conversationID.data).order(m.id.asc) @@ -223,7 +223,7 @@ nonisolated extension Database { /// Mirror one type's paged feed load: replaces that type's conversation + member sets (messages /// are retained, other types' conversations untouched), then stores each conversation's /// last-message preview. - func replaceConversationFeed(_ conversations: [Conversation], type: ConversationType) throws { + public func replaceConversationFeed(_ conversations: [Conversation], type: ConversationType) throws { let c = ConversationTable() let m = ConversationMemberTable() let ids = conversations.map(\.id.data) @@ -250,14 +250,14 @@ nonisolated extension Database { /// Advance the persisted catch-up cursor for a conversation without rewriting its members or /// last-message preview. No-ops for a conversation not yet in the feed. - func updateCatchupCursor(_ value: UInt64, for conversationID: ConversationID) throws { + public func updateCatchupCursor(_ value: UInt64, for conversationID: ConversationID) throws { let c = ConversationTable() try writer.run(c.table.filter(c.id == conversationID.data).update(c.catchupCursor <- value)) } /// Upsert one conversation: its row, its members (replaced wholesale), and /// its last-message preview row. - func upsertConversation(_ conversation: Conversation) throws { + public func upsertConversation(_ conversation: Conversation) throws { try writer.transaction { try writeConversation(conversation) } @@ -265,7 +265,7 @@ nonisolated extension Database { /// Upsert messages for a conversation (insert-or-replace on the (conversation, id) key). History is /// retained — the transcript reads a bounded window from it, so there is no prune. - func upsertConversationMessages(_ messages: [ConversationMessage], conversationID: ConversationID) throws { + public func upsertConversationMessages(_ messages: [ConversationMessage], conversationID: ConversationID) throws { try writer.transaction { for message in messages { try writeMessage(message, conversationId: conversationID.data) @@ -279,7 +279,7 @@ nonisolated extension Database { /// established (> 0) and only forward — a catch-up batch's interior checkpoint must not regress a /// cursor a live event already persisted. The conversation row need not exist yet (the update no-ops /// until it does). - func persistMessages(_ messages: [ConversationMessage], cursor: UInt64, conversationID: ConversationID) throws { + public func persistMessages(_ messages: [ConversationMessage], cursor: UInt64, conversationID: ConversationID) throws { let c = ConversationTable() try writer.transaction { for message in messages { @@ -298,7 +298,7 @@ nonisolated extension Database { /// Deletes a conversation's persisted messages — used when a freshly fetched newest page does not /// overlap the retained history, so a stale older epoch can't render seamlessly stitched to the new /// page across an unfetchable gap. - func deleteMessages(conversationID: ConversationID) throws { + public func deleteMessages(conversationID: ConversationID) throws { let m = ConversationMessageTable() try writer.run(m.table.filter(m.conversationId == conversationID.data).delete()) } diff --git a/Flipcash/Core/Controllers/Database/Database+Limits.swift b/FlipcashCore/Sources/FlipcashStore/Database+Limits.swift similarity index 74% rename from Flipcash/Core/Controllers/Database/Database+Limits.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Limits.swift index 2cdabbea7..f90c49df0 100644 --- a/Flipcash/Core/Controllers/Database/Database+Limits.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Limits.swift @@ -11,13 +11,13 @@ nonisolated extension Database { // MARK: - Get - - func getLimits() throws -> Limits? { + public func getLimits() throws -> Limits? { try getSingleton(Limits.self, in: LimitsTable()) } // MARK: - Insert - - func insertLimits(_ limits: Limits) throws { + public func insertLimits(_ limits: Limits) throws { try upsertSingleton(limits, in: LimitsTable()) } } diff --git a/Flipcash/Core/Controllers/Database/Database+Onboarding.swift b/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift similarity index 95% rename from Flipcash/Core/Controllers/Database/Database+Onboarding.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift index a4a2d9a3d..6f256e67b 100644 --- a/Flipcash/Core/Controllers/Database/Database+Onboarding.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift @@ -25,7 +25,7 @@ nonisolated extension Database { /// True once any completed incoming-money activity exists — the "added /// money" milestone. - func hasEverAddedMoney() throws -> Bool { + public func hasEverAddedMoney() throws -> Bool { let a = ActivityTable() return try reader.pluck( a.table.filter( @@ -39,7 +39,7 @@ nonisolated extension Database { /// the activity feed re-syncs in full from the server on every login, while /// chat messages sync lazily per conversation, so an account signed into on /// a fresh database has activity long before it has any messages. - func hasEverTipped(selfUserID: UserID) throws -> Bool { + public func hasEverTipped(selfUserID: UserID) throws -> Bool { try hasEverSentTipActivity() || hasEverSentTipMessage(selfUserID: selfUserID) } diff --git a/Flipcash/Core/Controllers/Database/Database+Profile.swift b/FlipcashCore/Sources/FlipcashStore/Database+Profile.swift similarity index 66% rename from Flipcash/Core/Controllers/Database/Database+Profile.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Profile.swift index 406437d47..45486f301 100644 --- a/Flipcash/Core/Controllers/Database/Database+Profile.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Profile.swift @@ -11,21 +11,21 @@ nonisolated extension Database { // MARK: - Get - - func getProfile() throws -> Profile? { + public func getProfile() throws -> Profile? { try getSingleton(Profile.self, in: ProfileTable()) } - func getUserFlags() throws -> UserFlags? { + public func getUserFlags() throws -> UserFlags? { try getSingleton(UserFlags.self, in: UserFlagsTable()) } // MARK: - Insert - - func insertProfile(_ profile: Profile) throws { + public func insertProfile(_ profile: Profile) throws { try upsertSingleton(profile, in: ProfileTable()) } - func insertUserFlags(_ userFlags: UserFlags) throws { + public func insertUserFlags(_ userFlags: UserFlags) throws { try upsertSingleton(userFlags, in: UserFlagsTable()) } } diff --git a/Flipcash/Core/Controllers/Database/Database+Rates.swift b/FlipcashCore/Sources/FlipcashStore/Database+Rates.swift similarity index 93% rename from Flipcash/Core/Controllers/Database/Database+Rates.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Rates.swift index 4e32786ad..21fe13329 100644 --- a/Flipcash/Core/Controllers/Database/Database+Rates.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Rates.swift @@ -17,7 +17,7 @@ nonisolated extension Database { /// rehydrate its in-memory cache on cold launch so screens render in /// the user's preferred currency before the live mint stream delivers /// its first batch. - func getRates() throws -> [Rate] { + public func getRates() throws -> [Rate] { let table = RateTable() let rows = try reader.prepareRowIterator(""" @@ -37,7 +37,7 @@ nonisolated extension Database { /// Write through a batch of rates from the live mint stream. Each /// row is keyed by currency code, so repeated stream updates for the /// same currency replace the previous row in place. - func upsertRates(_ rates: [Rate]) throws { + public func upsertRates(_ rates: [Rate]) throws { guard !rates.isEmpty else { return } let table = RateTable() diff --git a/Flipcash/Core/Controllers/Database/Database+Singleton.swift b/FlipcashCore/Sources/FlipcashStore/Database+Singleton.swift similarity index 82% rename from Flipcash/Core/Controllers/Database/Database+Singleton.swift rename to FlipcashCore/Sources/FlipcashStore/Database+Singleton.swift index 10126cc26..51a649ed0 100644 --- a/Flipcash/Core/Controllers/Database/Database+Singleton.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Singleton.swift @@ -5,10 +5,10 @@ import Foundation import FlipcashCore -import SQLite +public import SQLite /// A table holding exactly one JSON-encoded row, keyed `id = 1`. -nonisolated protocol SingletonTable { +nonisolated public protocol SingletonTable { static var name: String { get } var table: Table { get } var id: Expression { get } @@ -22,7 +22,7 @@ extension LimitsTable: SingletonTable {} nonisolated extension Database { /// Returns the singleton row decoded as `T`, or `nil` when the table is empty. - func getSingleton(_ type: T.Type, in table: S) throws -> T? { + public func getSingleton(_ type: T.Type, in table: S) throws -> T? { let statement = try reader.prepareRowIterator(""" SELECT t.data @@ -41,7 +41,7 @@ nonisolated extension Database { } /// Encodes `value` and writes it as the singleton row, replacing any existing one. - func upsertSingleton(_ value: T, in table: S) throws { + public func upsertSingleton(_ value: T, in table: S) throws { let data = try JSONEncoder().encode(value) try writer.run( diff --git a/Flipcash/Core/Controllers/Database/Database+UserProfiles.swift b/FlipcashCore/Sources/FlipcashStore/Database+UserProfiles.swift similarity index 85% rename from Flipcash/Core/Controllers/Database/Database+UserProfiles.swift rename to FlipcashCore/Sources/FlipcashStore/Database+UserProfiles.swift index 13944517e..ef4325b7a 100644 --- a/Flipcash/Core/Controllers/Database/Database+UserProfiles.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+UserProfiles.swift @@ -15,7 +15,7 @@ nonisolated extension Database { // MARK: - Get - /// The cached profile for `userID`, or `nil` when it hasn't been fetched yet. - func getUserProfile(userID: UserID) throws -> Profile? { + public func getUserProfile(userID: UserID) throws -> Profile? { let t = UserProfileTable() guard let row = try reader.pluck(t.table.filter(t.userID == userID)) else { return nil @@ -26,7 +26,7 @@ nonisolated extension Database { // MARK: - Insert - /// Cache `profile` under `userID`, replacing any existing row. - func upsertUserProfile(_ profile: Profile, userID: UserID) throws { + public func upsertUserProfile(_ profile: Profile, userID: UserID) throws { let t = UserProfileTable() let data = try JSONEncoder().encode(profile) try writer.run(t.table.upsert( @@ -39,7 +39,7 @@ nonisolated extension Database { // MARK: - Delete - /// Remove the cached profile for `userID`. - func deleteUserProfile(userID: UserID) throws { + public func deleteUserProfile(userID: UserID) throws { let t = UserProfileTable() try writer.run(t.table.filter(t.userID == userID).delete()) } diff --git a/Flipcash/Core/Controllers/Database/Database+VerifiedProtos.swift b/FlipcashCore/Sources/FlipcashStore/Database+VerifiedProtos.swift similarity index 100% rename from Flipcash/Core/Controllers/Database/Database+VerifiedProtos.swift rename to FlipcashCore/Sources/FlipcashStore/Database+VerifiedProtos.swift diff --git a/Flipcash/Core/Controllers/Database/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift similarity index 88% rename from Flipcash/Core/Controllers/Database/Database.swift rename to FlipcashCore/Sources/FlipcashStore/Database.swift index 90b154bf8..b95f6268f 100644 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -7,11 +7,11 @@ import Foundation import FlipcashCore -import SQLite +public import SQLite nonisolated private let logger = Logger(label: "flipcash.database") -typealias Expression = SQLite.Expression +public typealias Expression = SQLite.Expression // SQLite.swift serializes reads/writes through each `Connection`'s own // dispatch queue, so concurrent calls into `reader` and `writer` are safe @@ -21,7 +21,10 @@ typealias Expression = SQLite.Expression // The connections themselves are mutable now that they can be closed and // reopened, so `lock` — not isolation — is what makes that state safe. // FOLLOW-UP: Remove @unchecked when SQLite.swift declares Connection: Sendable. -nonisolated class Database: @unchecked Sendable { +// `open` rather than `public` so the test bundle can subclass it to tie temp-file cleanup to the +// database's lifetime. Every member stays `public`, so a subclass can add state but cannot override +// any behaviour. +nonisolated open class Database: @unchecked Sendable { private let storeURL: URL @@ -33,7 +36,7 @@ nonisolated class Database: @unchecked Sendable { private let lock = NSLock() /// The write connection, opening the store first if it is currently closed. - var writer: Connection { + public var writer: Connection { get throws { lock.lock() defer { lock.unlock() } @@ -49,7 +52,7 @@ nonisolated class Database: @unchecked Sendable { } /// The read connection, opening the store first if it is currently closed. - var reader: Connection { + public var reader: Connection { get throws { lock.lock() defer { lock.unlock() } @@ -66,7 +69,7 @@ nonisolated class Database: @unchecked Sendable { // MARK: - Init - - init(url: URL) throws { + public init(url: URL) throws { self.storeURL = url // Opening both here keeps an unusable store path failing at `init`, where it @@ -105,7 +108,7 @@ nonisolated class Database: @unchecked Sendable { /// captures the function in which this was called, otherwise /// it will always captured in transaction {} @inline(__always) - func transaction(silent: Bool = false, _ block: (Database) throws -> Void) rethrows { + public func transaction(silent: Bool = false, _ block: (Database) throws -> Void) rethrows { do { let connection = try writer let startChangeCount = connection.totalChanges @@ -150,7 +153,7 @@ nonisolated class Database: @unchecked Sendable { /// TRUNCATE rather than PASSIVE: a passive checkpoint gives up silently when any /// reader is mid-transaction, which is the case that leaves the WAL growing without /// bound. This blocks up to `busyTimeout` instead, and throws when it cannot finish. - func checkpoint() throws { + public func checkpoint() throws { try writer.run(Self.checkpointPragma) } @@ -164,7 +167,7 @@ nonisolated class Database: @unchecked Sendable { /// Nothing pairs with this. The next `reader` or `writer` access reopens the store /// and reapplies the pragmas, which is what lets a close arriving at an awkward /// moment heal itself instead of leaving the caller with a dead object. - func close() throws { + public func close() throws { lock.lock() defer { _reader = nil @@ -184,7 +187,7 @@ nonisolated class Database: @unchecked Sendable { /// /// The version file is deliberately left alone: the caller deletes the store because the /// recorded version is stale, and writes the new one immediately afterwards. - static func deleteStore(files: StoreLocation.Files) throws { + public static func deleteStore(files: StoreLocation.Files) throws { let urlsToRemove: [URL] = [ files.database, files.shm, @@ -198,7 +201,7 @@ nonisolated class Database: @unchecked Sendable { } } - static func setUserVersion(version: Int, files: StoreLocation.Files) throws { + public static func setUserVersion(version: Int, files: StoreLocation.Files) throws { try "\(version)".write( to: files.version, atomically: true, @@ -206,7 +209,7 @@ nonisolated class Database: @unchecked Sendable { ) } - static func userVersion(files: StoreLocation.Files) throws -> Int? { + public static func userVersion(files: StoreLocation.Files) throws -> Int? { let versionString = try String( contentsOf: files.version, encoding: .utf8 @@ -217,6 +220,6 @@ nonisolated class Database: @unchecked Sendable { } nonisolated extension Notification.Name { - static let databaseDidChange = Notification.Name("databaseDidChange") + public static let databaseDidChange = Notification.Name("databaseDidChange") } diff --git a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift b/FlipcashCore/Sources/FlipcashStore/Models/StoredBalance.swift similarity index 79% rename from Flipcash/Core/Controllers/Database/Models/StoredBalance.swift rename to FlipcashCore/Sources/FlipcashStore/Models/StoredBalance.swift index 5123769bd..ab100dc16 100644 --- a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift +++ b/FlipcashCore/Sources/FlipcashStore/Models/StoredBalance.swift @@ -8,25 +8,25 @@ import Foundation import FlipcashCore -nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { - let quarks: UInt64 - let symbol: String - let name: String - let supplyFromBonding: UInt64? - let sellFeeBps: Int? - let mint: PublicKey - let vmAuthority: PublicKey? - let updatedAt: Date - let imageURL: URL? - let costBasis: Double - - let usdf: FiatAmount - - var id: PublicKey { +nonisolated public struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { + public let quarks: UInt64 + public let symbol: String + public let name: String + public let supplyFromBonding: UInt64? + public let sellFeeBps: Int? + public let mint: PublicKey + public let vmAuthority: PublicKey? + public let updatedAt: Date + public let imageURL: URL? + public let costBasis: Double + + public let usdf: FiatAmount + + public var id: PublicKey { mint } - init(quarks: UInt64, symbol: String, name: String, supplyFromBonding: UInt64?, sellFeeBps: Int?, mint: PublicKey, vmAuthority: PublicKey?, updatedAt: Date, imageURL: URL?, costBasis: Double) throws { + public init(quarks: UInt64, symbol: String, name: String, supplyFromBonding: UInt64?, sellFeeBps: Int?, mint: PublicKey, vmAuthority: PublicKey?, updatedAt: Date, imageURL: URL?, costBasis: Double) throws { self.quarks = quarks self.symbol = symbol self.name = name @@ -69,7 +69,7 @@ nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { } } - func computeExchangedValue(with rate: Rate) -> ExchangedFiat { + public func computeExchangedValue(with rate: Rate) -> ExchangedFiat { .compute( onChainAmount: TokenAmount(quarks: quarks, mint: mint), rate: rate, @@ -79,7 +79,7 @@ nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { /// Computes the appreciation/depreciation of this balance. /// Returns a tuple with the ExchangedFiat (absolute value) and whether it's positive. - func computeAppreciation(with rate: Rate) -> (value: ExchangedFiat, isPositive: Bool) { + public func computeAppreciation(with rate: Rate) -> (value: ExchangedFiat, isPositive: Bool) { let appreciationUSD = usdf.value - Decimal(costBasis) let usdAbs = FiatAmount.usd(abs(appreciationUSD)) @@ -96,7 +96,7 @@ nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { } extension StoredBalance { - enum Error: Swift.Error { + public enum Error: Swift.Error { case missingStoredCoreMintForNonReserveToken } } @@ -111,7 +111,7 @@ nonisolated extension StoredBalance { /// The USD figure the wallet's token card renders for this balance: the /// stored value rounded to the cents a user actually sees. - var displayedUSDF: FiatAmount { + public var displayedUSDF: FiatAmount { usdf.roundedToSmallestUnit() } @@ -126,7 +126,7 @@ nonisolated extension StoredBalance { /// the stack positions cards by index with no per-card position animation, /// so the swap reads as a jump. The name settles cards showing the same /// figure, and no refresh changes a name. - static func walletOrder(_ lhs: StoredBalance, _ rhs: StoredBalance) -> Bool { + public static func walletOrder(_ lhs: StoredBalance, _ rhs: StoredBalance) -> Bool { let lhsDisplayed = lhs.displayedUSDF let rhsDisplayed = rhs.displayedUSDF diff --git a/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift b/FlipcashCore/Sources/FlipcashStore/Models/StoredMintMetadata.swift similarity index 76% rename from Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift rename to FlipcashCore/Sources/FlipcashStore/Models/StoredMintMetadata.swift index d270a7e08..e30663a7b 100644 --- a/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift +++ b/FlipcashCore/Sources/FlipcashStore/Models/StoredMintMetadata.swift @@ -8,39 +8,39 @@ import Foundation import FlipcashCore -nonisolated struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashable { +nonisolated public struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashable { - let mint: PublicKey - let name: String - let symbol: String - let decimals: Int - let bio: String? - let imageURL: URL? - let vmAddress: PublicKey? - let vmAuthority: PublicKey? - let lockDuration: Int? - let currencyConfig: PublicKey? - let liquidityPool: PublicKey? - let seed: PublicKey? - let authority: PublicKey? - let mintVault: PublicKey? - let coreMintVault: PublicKey? - let coreMintFees: PublicKey? - let supplyFromBonding: UInt64? - let sellFeeBps: Int? + public let mint: PublicKey + public let name: String + public let symbol: String + public let decimals: Int + public let bio: String? + public let imageURL: URL? + public let vmAddress: PublicKey? + public let vmAuthority: PublicKey? + public let lockDuration: Int? + public let currencyConfig: PublicKey? + public let liquidityPool: PublicKey? + public let seed: PublicKey? + public let authority: PublicKey? + public let mintVault: PublicKey? + public let coreMintVault: PublicKey? + public let coreMintFees: PublicKey? + public let supplyFromBonding: UInt64? + public let sellFeeBps: Int? - let socialLinks: String? - let billColors: String? + public let socialLinks: String? + public let billColors: String? - let createdAt: Date? + public let createdAt: Date? - let updatedAt: Date + public let updatedAt: Date - var id: PublicKey { + public var id: PublicKey { mint } - init(mint: PublicKey, name: String, symbol: String, decimals: Int, bio: String?, imageURL: URL?, vmAddress: PublicKey?, vmAuthority: PublicKey?, lockDuration: Int?, currencyConfig: PublicKey?, liquidityPool: PublicKey?, seed: PublicKey?, authority: PublicKey?, mintVault: PublicKey?, coreMintVault: PublicKey?, coreMintFees: PublicKey?, supplyFromBonding: UInt64?, sellFeeBps: Int?, socialLinks: String? = nil, billColors: String? = nil, createdAt: Date? = nil, updatedAt: Date) { + public init(mint: PublicKey, name: String, symbol: String, decimals: Int, bio: String?, imageURL: URL?, vmAddress: PublicKey?, vmAuthority: PublicKey?, lockDuration: Int?, currencyConfig: PublicKey?, liquidityPool: PublicKey?, seed: PublicKey?, authority: PublicKey?, mintVault: PublicKey?, coreMintVault: PublicKey?, coreMintFees: PublicKey?, supplyFromBonding: UInt64?, sellFeeBps: Int?, socialLinks: String? = nil, billColors: String? = nil, createdAt: Date? = nil, updatedAt: Date) { self.mint = mint self.name = name self.symbol = symbol @@ -68,7 +68,7 @@ nonisolated struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashab extension StoredMintMetadata { /// Converts StoredMintMetadata to MintMetadata - var metadata: MintMetadata { + public var metadata: MintMetadata { let vmMetadata: VMMetadata? = { guard let vmAddress = vmAddress, let vmAuthority = vmAuthority, @@ -134,14 +134,14 @@ extension StoredMintMetadata { extension StoredMintMetadata { /// Returns the JSON string persisted in the `socialLinks` column, or `nil` when empty. - nonisolated static func encodedSocialLinks(_ socialLinks: [SocialLink]) -> String? { + public nonisolated static func encodedSocialLinks(_ socialLinks: [SocialLink]) -> String? { guard !socialLinks.isEmpty, let data = try? JSONEncoder().encode(socialLinks) else { return nil } return String(data: data, encoding: .utf8) } /// Returns the JSON string persisted in the `billColors` column, or `nil` when empty. - nonisolated static func encodedBillColors(_ billColors: [String]) -> String? { + public nonisolated static func encodedBillColors(_ billColors: [String]) -> String? { guard !billColors.isEmpty, let data = try? JSONEncoder().encode(billColors) else { return nil } return String(data: data, encoding: .utf8) @@ -150,7 +150,7 @@ extension StoredMintMetadata { /// Creates a StoredMintMetadata from a MintMetadata for immediate display. /// Used when navigating from screens that already have the full metadata /// (e.g. Currency Discovery) to avoid a loading flash. - init(_ metadata: MintMetadata) { + public init(_ metadata: MintMetadata) { let encodedSocialLinks = Self.encodedSocialLinks(metadata.socialLinks) let encodedBillColors = Self.encodedBillColors(metadata.billColors) diff --git a/Flipcash/Core/Controllers/Database/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift similarity index 58% rename from Flipcash/Core/Controllers/Database/Schema.swift rename to FlipcashCore/Sources/FlipcashStore/Schema.swift index 0027b95ec..c27720099 100644 --- a/Flipcash/Core/Controllers/Database/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -8,287 +8,325 @@ import Foundation import FlipcashCore // @preconcurrency: SQLite.swift's Table and Expression not Sendable upstream. -@preconcurrency import SQLite +@preconcurrency public import SQLite -nonisolated struct BalanceTable: Sendable { - static let name = "balance" +nonisolated public struct BalanceTable: Sendable { + public static let name = "balance" - let table = Table(Self.name) - let quarks = Expression ("quarks") - let mint = Expression ("mint") - let costBasis = Expression ("costBasis") - let updatedAt = Expression ("updatedAt") + public init() {} + + public let table = Table(Self.name) + public let quarks = Expression ("quarks") + public let mint = Expression ("mint") + public let costBasis = Expression ("costBasis") + public let updatedAt = Expression ("updatedAt") } -nonisolated struct MintTable: Sendable { - static let name = "mint" +nonisolated public struct MintTable: Sendable { + public static let name = "mint" + + public init() {} - let table = Table(Self.name) - let mint = Expression ("mint") - let name = Expression ("name") - let symbol = Expression ("symbol") - let decimals = Expression ("decimals") - let bio = Expression ("bio") - let imageURL = Expression ("imageURL") + public let table = Table(Self.name) + public let mint = Expression ("mint") + public let name = Expression ("name") + public let symbol = Expression ("symbol") + public let decimals = Expression ("decimals") + public let bio = Expression ("bio") + public let imageURL = Expression ("imageURL") - let vmAddress = Expression ("vmAddress") - let vmAuthority = Expression ("vmAuthority") - let lockDuration = Expression ("lockDuration") + public let vmAddress = Expression ("vmAddress") + public let vmAuthority = Expression ("vmAuthority") + public let lockDuration = Expression ("lockDuration") - let currencyConfig = Expression ("currencyConfig") - let liquidityPool = Expression ("liquidityPool") - let seed = Expression ("seed") - let authority = Expression ("authority") - let mintVault = Expression ("mintVault") - let coreMintVault = Expression ("coreMintVault") - let coreMintFees = Expression ("coreMintFees") - let supplyFromBonding = Expression ("supplyFromBonding") - let sellFeeBps = Expression ("sellFeeBps") - - let socialLinks = Expression ("socialLinks") - let billColors = Expression ("billColors") - - let createdAt = Expression ("createdAt") - - let updatedAt = Expression ("updatedAt") + public let currencyConfig = Expression ("currencyConfig") + public let liquidityPool = Expression ("liquidityPool") + public let seed = Expression ("seed") + public let authority = Expression ("authority") + public let mintVault = Expression ("mintVault") + public let coreMintVault = Expression ("coreMintVault") + public let coreMintFees = Expression ("coreMintFees") + public let supplyFromBonding = Expression ("supplyFromBonding") + public let sellFeeBps = Expression ("sellFeeBps") + + public let socialLinks = Expression ("socialLinks") + public let billColors = Expression ("billColors") + + public let createdAt = Expression ("createdAt") + + public let updatedAt = Expression ("updatedAt") } -nonisolated struct ActivityTable: Sendable { - static let name = "activity" +nonisolated public struct ActivityTable: Sendable { + public static let name = "activity" + + public init() {} - let table = Table(Self.name) - let id = Expression ("id") - let kind = Expression ("kind") - let state = Expression ("state") - let title = Expression ("title") - let quarks = Expression ("quarks") // on-chain mint-native quarks - let nativeAmount = Expression ("nativeAmount") - let currency = Expression ("currency") - let mint = Expression ("mint") - let date = Expression ("date") + public let table = Table(Self.name) + public let id = Expression ("id") + public let kind = Expression ("kind") + public let state = Expression ("state") + public let title = Expression ("title") + public let quarks = Expression ("quarks") // on-chain mint-native quarks + public let nativeAmount = Expression ("nativeAmount") + public let currency = Expression ("currency") + public let mint = Expression ("mint") + public let date = Expression ("date") // The peer on a send/receive, for feed-row avatar + name enrichment. Both // nil for non-peer activity (deposits, buys, withdrawals). - let counterpartyUserID = Expression ("counterpartyUserID") - let counterpartyPhone = Expression ("counterpartyPhone") + public let counterpartyUserID = Expression ("counterpartyUserID") + public let counterpartyPhone = Expression ("counterpartyPhone") } -nonisolated struct CashLinkMetadataTable: Sendable { - static let name = "cashLinkMetadata" +nonisolated public struct CashLinkMetadataTable: Sendable { + public static let name = "cashLinkMetadata" - let table = Table(Self.name) - let id = Expression ("id") - let vault = Expression ("vault") - let canCancel = Expression ("canCancel") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let vault = Expression ("vault") + public let canCancel = Expression ("canCancel") } // Side table for `.swapped` activities: the two swap legs + fee. Joined 1:1 to // `activity` by id. The destination amount columns are nullable — a swap that // hasn't executed yet carries only its destination mint. -nonisolated struct SwapMetadataTable: Sendable { - static let name = "swapMetadata" - - let table = Table(Self.name) - let id = Expression ("id") - let fromMint = Expression ("fromMint") - let fromQuarks = Expression ("fromQuarks") - let fromNativeAmount = Expression ("fromNativeAmount") - let fromCurrency = Expression ("fromCurrency") - let toMint = Expression ("toMint") - let toQuarks = Expression ("toQuarks") - let toNativeAmount = Expression ("toNativeAmount") - let toCurrency = Expression ("toCurrency") - let feeNativeAmount = Expression ("feeNativeAmount") - let feeCurrency = Expression ("feeCurrency") - let state = Expression ("state") +nonisolated public struct SwapMetadataTable: Sendable { + public static let name = "swapMetadata" + + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let fromMint = Expression ("fromMint") + public let fromQuarks = Expression ("fromQuarks") + public let fromNativeAmount = Expression ("fromNativeAmount") + public let fromCurrency = Expression ("fromCurrency") + public let toMint = Expression ("toMint") + public let toQuarks = Expression ("toQuarks") + public let toNativeAmount = Expression ("toNativeAmount") + public let toCurrency = Expression ("toCurrency") + public let feeNativeAmount = Expression ("feeNativeAmount") + public let feeCurrency = Expression ("feeCurrency") + public let state = Expression ("state") } -nonisolated struct LimitsTable: Sendable { - static let name = "limits" +nonisolated public struct LimitsTable: Sendable { + public static let name = "limits" + + public init() {} - let table = Table(Self.name) - let id = Expression ("id") - let data = Expression ("data") + public let table = Table(Self.name) + public let id = Expression ("id") + public let data = Expression ("data") } -nonisolated struct RateTable: Sendable { - static let name = "rate" +nonisolated public struct RateTable: Sendable { + public static let name = "rate" - let table = Table(Self.name) - let currency = Expression ("currency") - let data = Expression ("data") + public init() {} + + public let table = Table(Self.name) + public let currency = Expression ("currency") + public let data = Expression ("data") } // Verified exchange-rate proofs, one per fiat currency. -nonisolated struct VerifiedRateTable: Sendable { - static let name = "verified_rate" +nonisolated public struct VerifiedRateTable: Sendable { + public static let name = "verified_rate" + + public init() {} - let table = Table(Self.name) - let currency = Expression ("currency") - let rateProto = Expression ("rateProto") + public let table = Table(Self.name) + public let currency = Expression ("currency") + public let rateProto = Expression ("rateProto") } -nonisolated struct ProfileTable: Sendable { - static let name = "profile" +nonisolated public struct ProfileTable: Sendable { + public static let name = "profile" - let table = Table(Self.name) - let id = Expression ("id") - let data = Expression ("data") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let data = Expression ("data") } /// Cache of *other* users' profiles, keyed by user id. Populated cache-through /// as profiles are fetched for display (chat counterparts, tip recipients, /// blocked users). The signed-in user's own profile stays in the singleton /// `profile` table. Stored as a JSON blob — reads are only ever by-key. -nonisolated struct UserProfileTable: Sendable { - static let name = "user_profile" +nonisolated public struct UserProfileTable: Sendable { + public static let name = "user_profile" + + public init() {} - let table = Table(Self.name) - let userID = Expression ("userID") // PK - let data = Expression ("data") // JSON-encoded Profile + public let table = Table(Self.name) + public let userID = Expression ("userID") // PK + public let data = Expression ("data") // JSON-encoded Profile } -nonisolated struct UserFlagsTable: Sendable { - static let name = "userFlags" +nonisolated public struct UserFlagsTable: Sendable { + public static let name = "userFlags" - let table = Table(Self.name) - let id = Expression ("id") - let data = Expression ("data") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let data = Expression ("data") } -nonisolated struct BlocklistTable: Sendable { - static let name = "blocklist" +nonisolated public struct BlocklistTable: Sendable { + public static let name = "blocklist" + + public init() {} - let table = Table(Self.name) - let userID = Expression ("userID") // PK - let blockedAt = Expression ("blockedAt") // timeIntervalSinceReferenceDate - let displayName = Expression ("displayName") - let avatarBlurhash = Expression ("avatarBlurhash") + public let table = Table(Self.name) + public let userID = Expression ("userID") // PK + public let blockedAt = Expression ("blockedAt") // timeIntervalSinceReferenceDate + public let displayName = Expression ("displayName") + public let avatarBlurhash = Expression ("avatarBlurhash") } // Verified reserve-state proofs, one per mint. -nonisolated struct VerifiedReserveTable: Sendable { - static let name = "verified_reserve" +nonisolated public struct VerifiedReserveTable: Sendable { + public static let name = "verified_reserve" + + public init() {} - let table = Table(Self.name) - let mint = Expression ("mint") - let reserveProto = Expression ("reserveProto") + public let table = Table(Self.name) + public let mint = Expression ("mint") + public let reserveProto = Expression ("reserveProto") } // Single-row table holding the contact-sync state machine cursor. // Primary key is always 1. -nonisolated struct ContactSyncStateTable: Sendable { - static let name = "contact_sync_state" +nonisolated public struct ContactSyncStateTable: Sendable { + public static let name = "contact_sync_state" - let table = Table(Self.name) - let id = Expression ("id") - let checksum = Expression ("checksum") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let checksum = Expression ("checksum") } // E.164 phones the server has confirmed are on Flipcash. -nonisolated struct FlipcashContactTable: Sendable { - static let name = "flipcash_contact" - - let table = Table(Self.name) - let e164 = Expression ("e164") - let dmChatId = Expression ("dmChatId") - let joinTs = Expression ("joinTs") - let matchedAt = Expression ("matchedAt") +nonisolated public struct FlipcashContactTable: Sendable { + public static let name = "flipcash_contact" + + public init() {} + + public let table = Table(Self.name) + public let e164 = Expression ("e164") + public let dmChatId = Expression ("dmChatId") + public let joinTs = Expression ("joinTs") + public let matchedAt = Expression ("matchedAt") } // Last contact set uploaded to the server. Joined with CNContactStore at // render time via `contactId` so name/avatar resolution stays current. -nonisolated struct LocalContactsSnapshotTable: Sendable { - static let name = "local_contacts_snapshot" +nonisolated public struct LocalContactsSnapshotTable: Sendable { + public static let name = "local_contacts_snapshot" + + public init() {} - let table = Table(Self.name) - let e164 = Expression ("e164") - let contactId = Expression ("contactId") + public let table = Table(Self.name) + public let e164 = Expression ("e164") + public let contactId = Expression ("contactId") } // DM conversation feed. Members and messages live in their own tables; the // feed's last-message preview is the newest row in `conversation_message`. // Dates are stored as raw `timeIntervalSinceReferenceDate` doubles — decoding // is a struct init instead of the bundled codec's per-row DateFormatter parse. -nonisolated struct ConversationTable: Sendable { - static let name = "conversation" +nonisolated public struct ConversationTable: Sendable { + public static let name = "conversation" - let table = Table(Self.name) - let id = Expression ("id") // 32-byte ChatId - let lastActivity = Expression ("lastActivity") + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") // 32-byte ChatId + public let lastActivity = Expression ("lastActivity") // Highest contiguous event-log sequence applied for this chat — the resume // point passed to GetDelta. Nil until the first catch-up establishes one. - let catchupCursor = Expression ("catchupCursor") + public let catchupCursor = Expression ("catchupCursor") // ConversationType raw value; scopes feed replaces and the Tips surfaces. - let type = Expression ("type") + public let type = Expression ("type") // Server-set: the counterpart is on the owner's blocklist. Retained so an // unblock restores the conversation; filtered from the displayed feed. - let isHidden = Expression ("isHidden") + public let isHidden = Expression ("isHidden") // Server-set title, group chats only. Nil for DMs. - let title = Expression ("title") + public let title = Expression ("title") } -nonisolated struct ConversationMemberTable: Sendable { - static let name = "conversation_member" +nonisolated public struct ConversationMemberTable: Sendable { + public static let name = "conversation_member" + + public init() {} - let table = Table(Self.name) - let conversationId = Expression ("conversationId") - let userId = Expression ("userId") - let displayName = Expression ("displayName") - let phoneE164 = Expression ("phoneE164") - let readPointer = Expression ("readPointer") - let readPointerTimestamp = Expression ("readPointerTimestamp") + public let table = Table(Self.name) + public let conversationId = Expression ("conversationId") + public let userId = Expression ("userId") + public let displayName = Expression ("displayName") + public let phoneE164 = Expression ("phoneE164") + public let readPointer = Expression ("readPointer") + public let readPointerTimestamp = Expression ("readPointerTimestamp") // Profile-picture rendition blob ids, when the member has a picture. - let profilePictureBlobID = Expression ("profilePictureBlobID") - let profilePictureThumbnailBlobID = Expression ("profilePictureThumbnailBlobID") + public let profilePictureBlobID = Expression ("profilePictureBlobID") + public let profilePictureThumbnailBlobID = Expression ("profilePictureThumbnailBlobID") // The thumbnail rendition's BlurHash preview, when present. - let profilePictureThumbnailBlurhash = Expression ("profilePictureThumbnailBlurhash") + public let profilePictureThumbnailBlurhash = Expression ("profilePictureThumbnailBlurhash") // The member's claimed handle, when they have one. Carried on the same // profile the feed embeds, so it is cached rather than refetched. - let username = Expression ("username") + public let username = Expression ("username") } // One row per message; cash content is decomposed across the amount columns // the same way `activity` stores ExchangedFiat. -nonisolated struct ConversationMessageTable: Sendable { - static let name = "conversation_message" - - let table = Table(Self.name) - let conversationId = Expression ("conversationId") - let id = Expression ("id") - let senderId = Expression ("senderId") - let kind = Expression ("kind") - let text = Expression ("text") - let quarks = Expression ("quarks") - let nativeAmount = Expression ("nativeAmount") - let currency = Expression ("currency") - let mint = Expression ("mint") +nonisolated public struct ConversationMessageTable: Sendable { + public static let name = "conversation_message" + + public init() {} + + public let table = Table(Self.name) + public let conversationId = Expression ("conversationId") + public let id = Expression ("id") + public let senderId = Expression ("senderId") + public let kind = Expression ("kind") + public let text = Expression ("text") + public let quarks = Expression ("quarks") + public let nativeAmount = Expression ("nativeAmount") + public let currency = Expression ("currency") + public let mint = Expression ("mint") // Cash delivery action (0 = sent, 1 = tipped); nil for non-cash rows. - let cashAction = Expression ("cashAction") - let date = Expression ("date") - let unreadSeq = Expression ("unreadSeq") + public let cashAction = Expression ("cashAction") + public let date = Expression ("date") + public let unreadSeq = Expression ("unreadSeq") // Event-log version of this message's current state; the store applies // last-writer-wins by it. Zero for legacy/optimistic rows. - let eventSequence = Expression ("eventSequence") + public let eventSequence = Expression ("eventSequence") // Stable client identity of an optimistic send, carried onto the server row it reconciles to so a // row keeps one identity across sending → sent and survives a DB round-trip. - let clientMessageID = Expression ("clientMessageID") + public let clientMessageID = Expression ("clientMessageID") // Reserved for the reply feature: written as nil and ignored on read. The column exists now // because the schema version can only be bumped once per rebuild, and adding it later would // cost users a second full resync. - let repliedToId = Expression ("repliedToId") + public let repliedToId = Expression ("repliedToId") // When the sender last edited this message; nil if never edited. - let lastEditedTs = Expression ("lastEditedTs") + public let lastEditedTs = Expression ("lastEditedTs") // Tombstone detail. Both nil for a message that has not been deleted. - let deletedBy = Expression ("deletedBy") - let deletedAt = Expression ("deletedAt") + public let deletedBy = Expression ("deletedBy") + public let deletedAt = Expression ("deletedAt") } // MARK: - Tables - nonisolated extension Database { - func createTablesIfNeeded() throws { + public func createTablesIfNeeded() throws { let balanceTable = BalanceTable() let mintTable = MintTable() let activityTable = ActivityTable() @@ -569,7 +607,7 @@ nonisolated extension UInt64: @retroactive Value { } } -nonisolated extension Key32: @retroactive Value { +nonisolated extension Key32: Value { public static var declaredDatatype: String { Blob.declaredDatatype } @@ -583,7 +621,7 @@ nonisolated extension Key32: @retroactive Value { } } -nonisolated extension CurrencyCode: @retroactive Value { +nonisolated extension CurrencyCode: Value { public static var declaredDatatype: String { String.declaredDatatype } diff --git a/Flipcash/Core/Controllers/Database/StoreMigration.swift b/FlipcashCore/Sources/FlipcashStore/StoreMigration.swift similarity index 98% rename from Flipcash/Core/Controllers/Database/StoreMigration.swift rename to FlipcashCore/Sources/FlipcashStore/StoreMigration.swift index 986d0bc09..fc53ff9f6 100644 --- a/Flipcash/Core/Controllers/Database/StoreMigration.swift +++ b/FlipcashCore/Sources/FlipcashStore/StoreMigration.swift @@ -16,9 +16,9 @@ nonisolated private let logger = Logger(label: "flipcash.database.migration") /// the process can be killed between any two file operations — and it has to survive a user who /// never launches the old build again, which is why nothing is left behind for a later pass to /// finish. -nonisolated enum StoreMigration { +nonisolated public enum StoreMigration { - enum Outcome: Equatable { + public enum Outcome: Equatable { /// Nothing to do: no store in either location, or the two locations are the same directory. case notNeeded /// A store was already at the shared location. Any legacy remnants were swept. @@ -34,7 +34,7 @@ nonisolated enum StoreMigration { /// /// Never throws. A migration that cannot complete degrades to a fresh store, which the app /// re-syncs; throwing here would instead block login on a file-system problem. - static func migrateIfNeeded( + public static func migrateIfNeeded( owner: PublicKey, location: StoreLocation, fileManager: FileManager = .default diff --git a/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift b/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift index a3fc78197..b6f845f59 100644 --- a/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift +++ b/FlipcashTests/AddMoney/AddMoneyProcessingViewModelTests.swift @@ -7,6 +7,7 @@ import Foundation import Testing @testable import Flipcash import FlipcashCore +import FlipcashStore @Suite("AddMoneyProcessingViewModel — settlement state machine") @MainActor diff --git a/FlipcashTests/AddMoneyGateTests.swift b/FlipcashTests/AddMoneyGateTests.swift index 94098493f..9c5e58853 100644 --- a/FlipcashTests/AddMoneyGateTests.swift +++ b/FlipcashTests/AddMoneyGateTests.swift @@ -7,6 +7,7 @@ import Foundation import Testing @testable import Flipcash import FlipcashCore +import FlipcashStore @Suite("AddMoneyGate") @MainActor struct AddMoneyGateTests { diff --git a/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift index cd5afa020..a5fcf60ff 100644 --- a/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift +++ b/FlipcashTests/Buy/BuyConfirmationViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("BuyConfirmationViewModel") diff --git a/FlipcashTests/Chat/ConversationReceiptWiringTests.swift b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift index c824b09f4..1262bca38 100644 --- a/FlipcashTests/Chat/ConversationReceiptWiringTests.swift +++ b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/Chat/ConversationReplySendTests.swift b/FlipcashTests/Chat/ConversationReplySendTests.swift index a27867207..c7a4183ec 100644 --- a/FlipcashTests/Chat/ConversationReplySendTests.swift +++ b/FlipcashTests/Chat/ConversationReplySendTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/Chat/MessageLoaderRevealTests.swift b/FlipcashTests/Chat/MessageLoaderRevealTests.swift index ae688c29b..98798005f 100644 --- a/FlipcashTests/Chat/MessageLoaderRevealTests.swift +++ b/FlipcashTests/Chat/MessageLoaderRevealTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/ContactSyncControllerTests.swift b/FlipcashTests/ContactSyncControllerTests.swift index 0651e5649..0ba419d99 100644 --- a/FlipcashTests/ContactSyncControllerTests.swift +++ b/FlipcashTests/ContactSyncControllerTests.swift @@ -7,6 +7,7 @@ import Contacts import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash // `.serialized` because the first-connect dialog tests mutate the shared diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 4d0792c74..5cf44da36 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/ConversationMutationTests.swift b/FlipcashTests/ConversationMutationTests.swift index d7522072f..2daed5e35 100644 --- a/FlipcashTests/ConversationMutationTests.swift +++ b/FlipcashTests/ConversationMutationTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/Convert/ConvertAmountViewModelTests.swift b/FlipcashTests/Convert/ConvertAmountViewModelTests.swift index a7fe598e4..6a18699f4 100644 --- a/FlipcashTests/Convert/ConvertAmountViewModelTests.swift +++ b/FlipcashTests/Convert/ConvertAmountViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("ConvertAmountViewModel — fee-affordable entry") diff --git a/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift b/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift index a70d420d4..c95c0db1a 100644 --- a/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift +++ b/FlipcashTests/CurrencyCreation/CurrencyPaymentSelectionViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("CurrencyPaymentSelectionViewModel") diff --git a/FlipcashTests/CurrencyInfoViewModelTests.swift b/FlipcashTests/CurrencyInfoViewModelTests.swift index 0369bd4c2..ae3be737c 100644 --- a/FlipcashTests/CurrencyInfoViewModelTests.swift +++ b/FlipcashTests/CurrencyInfoViewModelTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash /// Pins the loading-state churn behavior behind the Wallet → Currency Info diff --git a/FlipcashTests/Database/Database+BalanceUpsertTests.swift b/FlipcashTests/Database/Database+BalanceUpsertTests.swift index 8b6562e6e..17d02f749 100644 --- a/FlipcashTests/Database/Database+BalanceUpsertTests.swift +++ b/FlipcashTests/Database/Database+BalanceUpsertTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Balance upsert write gating") diff --git a/FlipcashTests/Database/Database+ContactSyncTests.swift b/FlipcashTests/Database/Database+ContactSyncTests.swift index 4ce140850..60074f5c5 100644 --- a/FlipcashTests/Database/Database+ContactSyncTests.swift +++ b/FlipcashTests/Database/Database+ContactSyncTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Database+ContactSync") diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 39f25d283..b49e720f5 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Conversation offline cache round-trip") diff --git a/FlipcashTests/Database/Database+LiveSupplyTests.swift b/FlipcashTests/Database/Database+LiveSupplyTests.swift index 0faa4064a..cfb92bc6f 100644 --- a/FlipcashTests/Database/Database+LiveSupplyTests.swift +++ b/FlipcashTests/Database/Database+LiveSupplyTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore import SQLite @testable import Flipcash diff --git a/FlipcashTests/Database/Database+MintUpsertTests.swift b/FlipcashTests/Database/Database+MintUpsertTests.swift index 8202122e0..475198c90 100644 --- a/FlipcashTests/Database/Database+MintUpsertTests.swift +++ b/FlipcashTests/Database/Database+MintUpsertTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash // `@MainActor` because `StoredMintMetadata.metadata` is main-actor-isolated diff --git a/FlipcashTests/Database/Database+OnboardingTests.swift b/FlipcashTests/Database/Database+OnboardingTests.swift index c13c73d6e..34f5e8bd4 100644 --- a/FlipcashTests/Database/Database+OnboardingTests.swift +++ b/FlipcashTests/Database/Database+OnboardingTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite(.serialized) diff --git a/FlipcashTests/Database/Database+ProfileTests.swift b/FlipcashTests/Database/Database+ProfileTests.swift index e10a4c311..6fbe15b29 100644 --- a/FlipcashTests/Database/Database+ProfileTests.swift +++ b/FlipcashTests/Database/Database+ProfileTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Profile + UserFlags offline cache round-trip") diff --git a/FlipcashTests/Database/Database+VerifiedProtosTests.swift b/FlipcashTests/Database/Database+VerifiedProtosTests.swift index c1962c00d..3ccef216b 100644 --- a/FlipcashTests/Database/Database+VerifiedProtosTests.swift +++ b/FlipcashTests/Database/Database+VerifiedProtosTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore @Suite("Database+VerifiedProtos") struct DatabaseVerifiedProtosTests { diff --git a/FlipcashTests/DatabaseLifecycleTests.swift b/FlipcashTests/DatabaseLifecycleTests.swift index 985614fee..afc1c69d4 100644 --- a/FlipcashTests/DatabaseLifecycleTests.swift +++ b/FlipcashTests/DatabaseLifecycleTests.swift @@ -4,6 +4,7 @@ // import Foundation +import FlipcashStore import Testing @testable import Flipcash diff --git a/FlipcashTests/HistoryControllerTests.swift b/FlipcashTests/HistoryControllerTests.swift index d644d721f..26d7eaedf 100644 --- a/FlipcashTests/HistoryControllerTests.swift +++ b/FlipcashTests/HistoryControllerTests.swift @@ -6,6 +6,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/MessageLoaderTests.swift b/FlipcashTests/MessageLoaderTests.swift index c1cd457be..80ef671ee 100644 --- a/FlipcashTests/MessageLoaderTests.swift +++ b/FlipcashTests/MessageLoaderTests.swift @@ -6,6 +6,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/RatesControllerTests.swift b/FlipcashTests/RatesControllerTests.swift index 56269564d..933037149 100644 --- a/FlipcashTests/RatesControllerTests.swift +++ b/FlipcashTests/RatesControllerTests.swift @@ -13,6 +13,7 @@ import Testing @preconcurrency import Combine @testable import Flipcash import FlipcashCore +import FlipcashStore import FlipcashAPI @Suite("RatesController") diff --git a/FlipcashTests/Regressions/Regression_69ea049e.swift b/FlipcashTests/Regressions/Regression_69ea049e.swift index ebb12ee5f..886e6b673 100644 --- a/FlipcashTests/Regressions/Regression_69ea049e.swift +++ b/FlipcashTests/Regressions/Regression_69ea049e.swift @@ -17,6 +17,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Regression: 69ea049e – SQLite off main in TransactionHistoryScreen", .bug("69ea049e0174bec1b4390000")) diff --git a/FlipcashTests/Regressions/Regression_69ea28b0.swift b/FlipcashTests/Regressions/Regression_69ea28b0.swift index ed8db639c..9df79b021 100644 --- a/FlipcashTests/Regressions/Regression_69ea28b0.swift +++ b/FlipcashTests/Regressions/Regression_69ea28b0.swift @@ -17,6 +17,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash // `@MainActor` because `StoredMintMetadata.init(_:)` and `.metadata` are diff --git a/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift b/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift index 49a4a04ae..adc68db90 100644 --- a/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift +++ b/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift @@ -18,6 +18,7 @@ import Foundation import Testing import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/Regressions/Regression_sell_max_precision.swift b/FlipcashTests/Regressions/Regression_sell_max_precision.swift index 46f8dc4c2..934c898c9 100644 --- a/FlipcashTests/Regressions/Regression_sell_max_precision.swift +++ b/FlipcashTests/Regressions/Regression_sell_max_precision.swift @@ -12,6 +12,7 @@ import Testing // @preconcurrency: BigDecimal.Rounding not Sendable upstream. @preconcurrency import BigDecimal import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Regression: sell-max precision (newly-minted bonding-curve balance)") diff --git a/FlipcashTests/SessionTests.swift b/FlipcashTests/SessionTests.swift index 2871661a8..94835dfbb 100644 --- a/FlipcashTests/SessionTests.swift +++ b/FlipcashTests/SessionTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing @testable import FlipcashCore +import FlipcashStore @testable import Flipcash @MainActor diff --git a/FlipcashTests/StoreMigrationTests.swift b/FlipcashTests/StoreMigrationTests.swift index 3a4ce53ea..97b82c367 100644 --- a/FlipcashTests/StoreMigrationTests.swift +++ b/FlipcashTests/StoreMigrationTests.swift @@ -7,6 +7,7 @@ import Foundation import Testing import SQLite import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("Store migration") diff --git a/FlipcashTests/StoredBalanceAppreciationTests.swift b/FlipcashTests/StoredBalanceAppreciationTests.swift index 75df972c4..2eb2af551 100644 --- a/FlipcashTests/StoredBalanceAppreciationTests.swift +++ b/FlipcashTests/StoredBalanceAppreciationTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash @Suite("StoredBalance - Appreciation") diff --git a/FlipcashTests/TestSupport/Database+TestSupport.swift b/FlipcashTests/TestSupport/Database+TestSupport.swift index f9e929373..26cce02cc 100644 --- a/FlipcashTests/TestSupport/Database+TestSupport.swift +++ b/FlipcashTests/TestSupport/Database+TestSupport.swift @@ -4,6 +4,7 @@ // import Foundation +import FlipcashStore @testable import Flipcash extension Database { diff --git a/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift b/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift index 7d981f62a..a87eb4961 100644 --- a/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift +++ b/FlipcashTests/TestSupport/Database+VerifiedProtosTestSupport.swift @@ -6,6 +6,7 @@ import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore import SQLite /// Single-row read helpers used only in tests. Production reads the whole diff --git a/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift b/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift index d3e508a44..c649b910b 100644 --- a/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift +++ b/FlipcashTests/TestSupport/ExchangedBalance+TestSupport.swift @@ -5,6 +5,7 @@ import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash extension ExchangedBalance { diff --git a/FlipcashTests/TestSupport/MockSession.swift b/FlipcashTests/TestSupport/MockSession.swift index 738d6381b..f69265201 100644 --- a/FlipcashTests/TestSupport/MockSession.swift +++ b/FlipcashTests/TestSupport/MockSession.swift @@ -6,6 +6,7 @@ import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore /// Closure-driven mock conforming to every Session capability protocol. /// Unset handlers throw `MockSessionError.unimplemented`. diff --git a/FlipcashTests/TestSupport/Mocks.swift b/FlipcashTests/TestSupport/Mocks.swift index 178968395..1378c3d6c 100644 --- a/FlipcashTests/TestSupport/Mocks.swift +++ b/FlipcashTests/TestSupport/Mocks.swift @@ -12,6 +12,7 @@ import Foundation @testable import Flipcash import FlipcashCore +import FlipcashStore extension Database { /// Fresh, per-access SQLite file. Each read of `.mock` returns a new diff --git a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift index c6b34eb5d..794fe7c84 100644 --- a/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift +++ b/FlipcashTests/TestSupport/SessionContainer+TestSupport.swift @@ -7,6 +7,7 @@ import Foundation import FlipcashCore +import FlipcashStore @testable import Flipcash extension SessionContainer { diff --git a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift index 39345f207..ecd62f1d0 100644 --- a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift +++ b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import Testing import FlipcashCore +import FlipcashStore import FlipcashUI @testable import FlipcashCore @testable import Flipcash From 3e63bffe0ba5e2a3bc9be9a5593efbe72b6a400d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 13:26:48 -0400 Subject: [PATCH 4/5] feat(notifications): write prefetched messages into the shared store (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(notifications): write prefetched messages into the shared store The extension already fetches five messages on every chat push. Until now they went only into `NotificationPreviewCache`, a JSON side-car the content extension reads and the app never opens, so the app refetched the same messages on launch. Now the extension also writes them through `Database.persistMessages`. The side-car stays: it is the content extension's only source, and nothing about this change replaces it. `ExtensionStore.perform` wraps the write in the cycle the extension has to use — open, write, checkpoint, close — inside `performExpiringActivity`, so the process is not suspended holding the App Group store's lock. It refuses to run in two cases: - No store file. `Database.init` would create one, and an empty store at the shared path reads to `StoreMigration` as a finished migration, which would make the app delete the legacy store. - A recorded schema version that is not this build's. Rebuilding a store belongs to the app, not to a 30-second extension. The schema version moves from the app's `SQLiteVersion` Info.plist key to `Database.schemaVersion`. An extension cannot read the app's Info.plist, and both targets link `FlipcashStore`, so they cannot disagree about the number. The write runs before `deliver()`. It costs the banner a few milliseconds plus roughly 100 ms of checkpoint, but after delivery nothing keeps the process alive. Two deliberate limits: the catch-up cursor is not advanced, because the extension fetched a bounded preview rather than a delta and advancing it would make the next sync skip the gap; and no conversation row is synthesized, so a conversation the app has never seen stays absent from the feed until sync introduces it. * chore(deps): bump flipcash2-client-protocol to 0.5.0 0.5.0 adds `push.v1.ChatMetadata.message`, the chat message a push is notifying about, carried inline. It also marks `ChatMetadata.sending_user_id` deprecated in favour of reading the sender off that message. The bump on its own changes no behaviour. `Flipcash_Push_V1_Payload` moves to heap storage and so becomes `@unchecked Sendable` rather than `Sendable`, which is generated-code bookkeeping, not a contract change. * feat(notifications): persist the message embedded in the push Every store row the extension wrote came from a network fetch, so a push that arrived with no usable connection wrote nothing — the case where a warm store matters most, because the app is about to cold-start too. `ChatMetadata.message` carries the message the push is notifying about, so the newest message needs no transport to become a store row. `NotificationPayload.chatMessage` decodes it through the existing `ConversationMessage.init?(_:)`, which means unrepresentable content is dropped the same way it is everywhere else rather than becoming an empty row. It is merged with the fetched transcript rather than written separately: both sources carry the same `eventSequence` for the message they share, and one store cycle per push keeps the cost at one checkpoint. The fetched copy wins a tie, being the one the server rendered most recently. The two paths that used to write nothing — an empty fetch, and a transport failure — now write the embedded message if the push carried one. `persist` returns early on an empty array so neither path opens the store to write no rows. * test(database): cover the migration against the real containers Every existing migration test builds its own `StoreLocation` from two temporary directories. That leaves the first step of a real upgrade untested: resolving the App Group container. With the entitlement inactive at runtime, `resolved` falls back to Application Support, both sides name the same file, the migration reports `.notNeeded`, and the extension never sees the store — a silent failure that an injected location cannot produce. This suite uses `StoreLocation.resolved()` and the real directories: it seeds a WAL-mode store and a version file where a shipped build leaves them, migrates, then reads the rows back through `Database` and again through `ExtensionStore`, which resolves the container itself. Files are named from a random owner key, and store paths are owner-scoped, so nothing here can name a real account's store. 16 tests pass on an iPhone 16 Pro, including the three new ones. --- .../xcshareddata/swiftpm/Package.resolved | 6 +- .../Controllers/ContactSyncController.swift | 6 +- .../Onramp/CoinbaseOrderEmail.swift | 2 +- .../Core/Session/SessionAuthenticator.swift | 2 +- Flipcash/Supporting Files/Info.plist | 2 - FlipcashAPI/Package.swift | 2 +- .../Push/NotificationPayload.swift | 15 + .../Sources/FlipcashStore/Database.swift | 12 + .../FlipcashStore/ExtensionStore.swift | 105 +++++++ .../FlipcashCoreTests/ProfileTests.swift | 4 +- .../Push/NotificationPayloadTests.swift | 80 ++++++ .../ContactSyncControllerTests.swift | 4 +- FlipcashTests/ExtensionStoreTests.swift | 267 ++++++++++++++++++ .../StoreMigrationContainerTests.swift | 162 +++++++++++ NotificationService/NotificationService.swift | 143 +++++++++- 15 files changed, 791 insertions(+), 21 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift create mode 100644 FlipcashTests/ExtensionStoreTests.swift create mode 100644 FlipcashTests/StoreMigrationContainerTests.swift 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/Controllers/ContactSyncController.swift b/Flipcash/Core/Controllers/ContactSyncController.swift index 43eab2c01..4607c68ae 100644 --- a/Flipcash/Core/Controllers/ContactSyncController.swift +++ b/Flipcash/Core/Controllers/ContactSyncController.swift @@ -51,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 { @@ -276,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 } @@ -546,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/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/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 5aa9af056..6710ac1d5 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -331,7 +331,7 @@ final class SessionAuthenticator { // the user version is outdated, we'll rebuild the // database during sync. let userVersion = (try? Database.userVersion(files: files)) ?? 0 - let currentVersion = try InfoPlist.value(for: "SQLiteVersion").integer() + let currentVersion = Database.schemaVersion if currentVersion > userVersion { try Database.deleteStore(files: files) logger.error("Outdated user version, deleted database.") 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/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/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index b95f6268f..de86c3409 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -183,6 +183,18 @@ nonisolated open class Database: @unchecked Sendable { // 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 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/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/FlipcashTests/ContactSyncControllerTests.swift b/FlipcashTests/ContactSyncControllerTests.swift index 0ba419d99..f09781f6c 100644 --- a/FlipcashTests/ContactSyncControllerTests.swift +++ b/FlipcashTests/ContactSyncControllerTests.swift @@ -779,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] @@ -791,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/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/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/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?() + } + } + } From fe6fef5079fa43332d29feb91d441c0a99452c02 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 11 Sep 2026 13:26:59 -0400 Subject: [PATCH 5/5] refactor(chat): drop the deprecated new_messages overlay (#757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChatUpdate.new_messages` is marked `[deprecated = true]` in the contract, superseded by the sequenced, gap-detectable `events` batch. The decode path kept it as an additive overlay so a message arriving only there was never dropped; the backend now sends `events` and leaves `new_messages` empty, so the overlay only costs a second pass over an empty batch and an enum case every switch has to carry. `.chatEvents` already covers what the `.newMessages` arm did. The store's last-activity bump is the same, restricted to `.sent` mutations so an edit or delete can't move a feed row on its original low id; the controller's persist arm matches, plus the cursor write that makes messages and the advanced frontier land atomically. Ported the ordering and messages-plus-typing decode tests to `events`, and the receipt-wiring and backlog tests to `.chatEvents` via a new `ConversationStreamEvent.sent` helper. Deleted the tests that only covered the overlay itself, including the store's last-activity test — `chatEventsBumpsActivity` already asserts it. --- .../Controllers/ConversationController.swift | 17 +----- .../Conversation/ConversationStore.swift | 5 -- .../ConversationStreamEvent.swift | 16 +----- .../ConversationStoreTests.swift | 8 --- .../ConversationStreamEventDecodeTests.swift | 55 ++----------------- .../Chat/ConversationReceiptWiringTests.swift | 10 ++-- .../ConversationControllerTests.swift | 6 +- .../Conversation+TestSupport.swift | 15 +++++ 8 files changed, 32 insertions(+), 100 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b8539e6b7..682e36d94 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -451,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 @@ -614,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/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/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/FlipcashTests/Chat/ConversationReceiptWiringTests.swift b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift index 1262bca38..b7f335edc 100644 --- a/FlipcashTests/Chat/ConversationReceiptWiringTests.swift +++ b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift @@ -76,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) @@ -126,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)) @@ -153,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/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 5cf44da36..f24366b3b 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -506,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 } @@ -535,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 } @@ -957,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/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) } + ) + ]) + } +}