Skip to content

feat(notifications): write prefetched messages into the shared store - #756

Merged
bmc08gt merged 4 commits into
refactor/shared-store-packagefrom
feat/nse-store-writes
Sep 11, 2026
Merged

bmc08gt merged 4 commits into
refactor/shared-store-packagefrom
feat/nse-store-writes

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

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.cachePreview now also calls Database.persistMessages with 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.perform is the whole cycle an extension has to use around a store in shared storage: open, write, checkpoint, close, inside performExpiringActivity. Holding the App Group store's lock while the process is suspended is the case iOS kills with 0xdead10cc; the expiring-activity assertion is what keeps the process alive until close() returns. The close() it calls landed in #753.

It refuses to run in two cases, and both are guards rather than niceties:

  • No store file. Database.init creates one if it is missing. An empty store at the shared path reads to StoreMigration as 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.
  • A recorded schema version that is not this build's. Both directions no-op. Deleting and rebuilding a store from sync is the app's job; an extension with 30 seconds should not start it.

Everything else — a busy lock, a thrown write, an expired assertion — returns without a store left open, because close() is in a defer.

The embedded message

Bumping flipcash2-client-protocol to 0.5.0 brings push.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.chatMessage decodes it through the existing ConversationMessage.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 on 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 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_id in favour of reading the sender off the embedded message. Nothing here reads it yet; the substitution path still works off titleSubstitutions.

Schema version moved out of Info.plist

SQLiteVersion was an Info.plist integer the app read through InfoPlist.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 now Database.schemaVersion in FlipcashStore, 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 with cursor: 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. After deliver() there is nothing keeping the process alive, which is the wrong place to be mid-write.

Tests

ExtensionStoreTests covers 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 -wal is 0 bytes after the call, a throwing body still closes, and a held BEGIN EXCLUSIVE produces .busy rather than a hang.

NotificationPayloadTests covers the new accessor: the embedded message maps through, its eventSequence survives (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.

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.
@bmc08gt bmc08gt self-assigned this Sep 11, 2026
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
bmc08gt merged commit e1c3dc3 into refactor/shared-store-package Sep 11, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant