Skip to content

chore(stack): restore #753–#757 onto main - #758

Merged
bmc08gt merged 5 commits into
mainfrom
recover/push-preload-stack
Sep 11, 2026
Merged

bmc08gt merged 5 commits into
mainfrom
recover/push-preload-stack

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

#753 through #757 were a stack, each based on the one below it. They were merged bottom-up, but the repo has delete_branch_on_merge off, so merging a parent left its branch in place and GitHub never retargeted the children onto main. Each child then squashed into its original base branch. main got #751 and nothing after it.

This branch is main with those five squash commits cherry-picked back in order:

Commit PR
8b700b42 #753 — open the store on demand, close it on background
2b924e5d #754 — move the SQLite store into the App Group container
788051bd #755 — move the persistence layer into a shared FlipcashStore package
3e63bffe #756 — write prefetched messages into the shared store
fe6fef50 #757 — drop the deprecated new_messages overlay

All five applied without conflict. The branch differs from feat/nse-store-writes in 27 files, and all 27 are commits that landed on main after the stack forked at 22dabe4a — the stack never had them.

Merge this with rebase, not squash: the five commits already carry their (#NNN) suffixes and should land individually, matching the rest of the history. A squash would collapse five PRs into one commit.

Once this lands, the five stale branches (feat/push-preload-groundwork, feat/database-connection-lifecycle, feat/database-app-group-store, refactor/shared-store-package, feat/nse-store-writes) can be deleted.

* 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.
)

* 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.
…re package (#755)

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.
…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.
`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.
@bmc08gt bmc08gt self-assigned this Sep 11, 2026
@bmc08gt
bmc08gt merged commit e1a7552 into main Sep 11, 2026
1 check passed
@bmc08gt
bmc08gt deleted the recover/push-preload-stack branch September 15, 2026 17:48
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