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?()
+ }
+ }
+
}