feat(database): move the SQLite store into the App Group container - #754
Merged
bmc08gt merged 4 commits intoSep 11, 2026
Merged
Conversation
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.
… 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.
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.
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.
bmc08gt
added a commit
that referenced
this pull request
Sep 11, 2026
) * 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.
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.
The store lives in the app's private Application Support directory, which the notification service extension cannot open. It already prefetches five messages on every push and writes them into a JSON side-car that only the notification UI reads, because there is nowhere else to put them. This moves the store into
group.com.flipcash.shared, which is what gives that fetch a destination the app reads on launch.Paths in FlipcashCore, the move in the app target
StoreLocationreplaces the fourURLextensions onDatabase—dataStore,storeWAL,storeSHM,versionFile. It resolves the container, names the four files for an owner, and names them again in the pre-move directory. It is paths only, and never opens or moves anything:FlipcashCorehas no SQLite dependency and this does not add one.resolved()takes the container lookup as a parameter becausecontainerURL(forSecurityApplicationGroupIdentifier:)is not consistent across platforms. On iOS it returns nil without the entitlement; on macOS, where this package's tests run, it returns a constructed path for any identifier. Passing a bogus group name therefore cannot reach the nil branch, and the seam is what makes the fallback testable.That fallback keeps the store in Application Support and sets
isSharedto false. It means the entitlement is missing or the group is not provisioned — a build configuration problem — and the app works, with an extension that cannot see the store.SessionAuthenticatorreports it, sinceFlipcashCorehas no reporting channel.The migration runs once, at the point the store opens
StoreMigration.migrateIfNeededis called frominitializeDatabase(owner:), before the version check and the open. Two ordering decisions carry it:Checkpoint before moving. A store in WAL mode is up to three files, and rows that were committed but not yet folded in live only in the
-wal.PRAGMA wal_checkpoint(TRUNCATE)first means one file moves and there is no window where the database and its log are in different directories.Version file before database. The move is two
moveItemcalls and the process can die between them. In this order, a launch that dies after the first one finds no database at the destination, retries, and treats the already-moved version file as done. The reverse order leaves a store whose version is unreadable, which reads as 0 and triggers a full rebuild on next sync.Failure recovery is scoped by whether the destination already held a store when the migration started. If it did, nothing at the destination is touched — the legacy files are leftovers to sweep, not data to adopt. If it did not, a partial move is cleared so the next launch starts from the legacy copy rather than a half-written one. Either way the outcome is reported and the app continues; a migration that cannot finish is not a reason to refuse to launch.
Opening the writer first is now load-bearing
A migrated store arrives as a lone
.sqlite: the log was folded in and the-shmswept. A read-only connection to a WAL-mode database with no-shmbeside it fails withunable to open database file, because it cannot create the shared-memory file it needs.Database.initopens its writer before its reader, so the writer creates it.That ordering was incidental before and is not now. The extension in the next PR opens the same store from a second process and has to respect it.
Stacked
Branches off
feat/database-connection-lifecycle(#753), which carriesclose(). Retarget once that and #751 merge.