Repository navigation
feat(notifications): write prefetched messages into the shared store - #756
Merged
Merged
Conversation
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.
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.
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.
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.
bmc08gt
added a commit
that referenced
this pull request
Sep 11, 2026
…756) * 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.
bmc08gt
added a commit
that referenced
this pull request
Sep 11, 2026
…discrete-curve * origin/main: (27 commits) fix(database): share one SQLite writer per owner and take write locks up front (#759) feat(chat): declare the payment action on tip DM payments (#752) refactor(chat): drop the deprecated new_messages overlay (#757) feat(notifications): write prefetched messages into the shared store (#756) refactor(store): move the persistence layer into a shared FlipcashStore package (#755) feat(database): move the SQLite store into the App Group container (#754) feat(database): open the store on demand, close it on background (#753) feat(nse): extension crash reporting, a WAL checkpoint, and on-device push hooks (#751) feat(home): long-press the You tab to open the account switcher (#749) fix(tests): reset Photos access before the previous app instance lingers (#746) chore: bump version to 2026.9.2 (#745) revert: back out the Coinbase Stable Swapper authority migration (#747) (#750) fix(swap): follow the Coinbase Stable Swapper authority migration (#747) fix(tests): cancel a cash link through the details screen (#744) fix(chat): make the whole Send Cash pill tappable while it stands alone (#743) fix(username): drop a leading @ in the validator (#742) fix(chat): scope the send-button spring to the button (#741) fix(transactions): tighten the details card stack and drop the header badge (#740) fix(transactions): draw View in Chat as a card, not the primary action (#739) feat(chat): flash the message a reply-quote jump lands on (#738) ... # Conflicts: # Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved # FlipcashCore/Package.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Last of the five. The extension already fetches five messages on every chat push; they went only into
NotificationPreviewCache, a JSON side-car the content extension reads and the app never opens. The app then refetched the same messages on launch. This gives that fetch a destination the app reads, and adds a second source that needs no fetch at all.Stacked on #755. Review that one first.
What changed
NotificationService.cachePreviewnow also callsDatabase.persistMessageswith the messages it just fetched. The side-car is unchanged — it is the content extension's only source, so this is a second destination, not a replacement.ExtensionStore.performis the whole cycle an extension has to use around a store in shared storage: open, write, checkpoint, close, insideperformExpiringActivity. Holding the App Group store's lock while the process is suspended is the case iOS kills with0xdead10cc; the expiring-activity assertion is what keeps the process alive untilclose()returns. Theclose()it calls landed in #753.It refuses to run in two cases, and both are guards rather than niceties:
Database.initcreates one if it is missing. An empty store at the shared path reads toStoreMigrationas a finished migration, and the app would then delete the legacy store. That is data loss out of a push arriving before the first migrated launch.Everything else — a busy lock, a thrown write, an expired assertion — returns without a store left open, because
close()is in adefer.The embedded message
Bumping
flipcash2-client-protocolto 0.5.0 bringspush.v1.ChatMetadata.message: the message the push is notifying about, carried inline. That matters because every store row until now came from a network fetch, so a push arriving with no usable connection wrote nothing — the case where a warm store is worth most, since the app is about to cold-start too.NotificationPayload.chatMessagedecodes it through the existingConversationMessage.init?(_:), so content this client can't draw is dropped the way it is everywhere else rather than becoming an empty row. It merges with the fetched transcript instead of being written separately: the two agree oneventSequencefor 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 previously wrote nothing — an empty fetch and a transport failure — now write the embedded message when the push carried one.
0.5.0 also deprecates
ChatMetadata.sending_user_idin favour of reading the sender off the embedded message. Nothing here reads it yet; the substitution path still works offtitleSubstitutions.Schema version moved out of Info.plist
SQLiteVersionwas an Info.plist integer the app read throughInfoPlist.value(for:). The extension needs the same number to make the version check above, and it cannot read the app's Info.plist — separate bundles. It is nowDatabase.schemaVersioninFlipcashStore, which both targets link, so they cannot disagree. The plist key is removed rather than left behind for someone to bump.Two calls worth disagreeing with
The cursor is not advanced.
persistMessages(_:cursor:conversationID:)is called withcursor: 0. The extension fetched a bounded five-message preview, not a delta; advancing the catch-up cursor past it would make the next sync treat the gap as already covered.No conversation row is synthesized. A push for a conversation the app has never seen writes the messages and nothing else, so the conversation stays absent from the feed until sync introduces it. Inventing a row from what a push carries is a bigger change than this PR, and it would be guessing at fields sync knows.
The write runs before
deliver(). It costs the banner a few milliseconds plus roughly 100 ms of checkpoint. Afterdeliver()there is nothing keeping the process alive, which is the wrong place to be mid-write.Tests
ExtensionStoreTestscovers the guards and the cycle: a missing store creates nothing, an older and a newer recorded schema are both skipped, writes are visible to a subsequent app-side read, the cursor stays where it was, repeated deliveries merge, a stale delivery loses to a newer sequence, the-walis 0 bytes after the call, a throwing body still closes, and a heldBEGIN EXCLUSIVEproduces.busyrather than a hang.NotificationPayloadTestscovers the new accessor: the embedded message maps through, itseventSequencesurvives (which is what lets it merge rather than duplicate), and it returns nil for a server that omits the field, a non-chat category, and content the client can't represent.