Skip to content

feat(database): move the SQLite store into the App Group container - #754

Merged
bmc08gt merged 4 commits into
feat/database-connection-lifecyclefrom
feat/database-app-group-store
Sep 11, 2026
Merged

feat(database): move the SQLite store into the App Group container#754
bmc08gt merged 4 commits into
feat/database-connection-lifecyclefrom
feat/database-app-group-store

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

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

StoreLocation replaces the four URL extensions on DatabasedataStore, 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: FlipcashCore has no SQLite dependency and this does not add one.

resolved() takes the container lookup as a parameter because containerURL(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 isShared to 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. SessionAuthenticator reports it, since FlipcashCore has no reporting channel.

The migration runs once, at the point the store opens

StoreMigration.migrateIfNeeded is called from initializeDatabase(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 moveItem calls 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 -shm swept. A read-only connection to a WAL-mode database with no -shm beside it fails with unable to open database file, because it cannot create the shared-memory file it needs. Database.init opens 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 carries close(). Retarget once that and #751 merge.

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 bmc08gt self-assigned this Sep 11, 2026
@bmc08gt
bmc08gt merged commit d2fe844 into feat/database-connection-lifecycle Sep 11, 2026
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
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