From 0048de36add3a53b7cbb890c1cd240917b601c79 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 19:09:58 -0400 Subject: [PATCH 1/4] feat(core): add StoreLocation for the App Group store paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../FlipcashCore/Storage/StoreLocation.swift | 122 +++++++++++++++ .../StoreLocationTests.swift | 145 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift b/FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift new file mode 100644 index 000000000..754eee786 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Storage/StoreLocation.swift @@ -0,0 +1,122 @@ +// +// StoreLocation.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Where the SQLite store lives, and where it used to live. +/// +/// The store started in the app's private Application Support directory, which the notification +/// extensions cannot open — separate processes, separate containers. Moving it into the App Group +/// container is what lets the extension write messages that the app will later read. +/// +/// This type is paths only. It resolves the container and names the files; it never opens or moves +/// anything. That keeps it testable against temporary directories, and keeps `FlipcashCore` free of +/// a SQLite dependency it does not otherwise have. +public struct StoreLocation: Sendable { + + /// The App Group shared by the app and both notification extensions. + public static let appGroup = NotificationPreviewCache.appGroup + + /// Where the store lives now. + public let directory: URL + + /// Where the store lived before the App Group move, and where a pre-move install still has it. + public let legacyDirectory: URL + + /// False when the App Group container could not be resolved and `directory` fell back to + /// `legacyDirectory`. + /// + /// That happens when the entitlement is missing or the group is not provisioned for the running + /// build — a build configuration problem rather than a runtime condition to recover from. + /// Falling back keeps the app working, with an extension that cannot see the store, instead of + /// refusing to launch. The flag is here so the caller can report it: `FlipcashCore` has no + /// reporting channel of its own. + public let isShared: Bool + + public init(directory: URL, legacyDirectory: URL, isShared: Bool) { + self.directory = directory + self.legacyDirectory = legacyDirectory + self.isShared = isShared + } + + /// Resolves the App Group container, falling back to the legacy directory when it is missing. + /// + /// `containerURL` is injected rather than called directly because the lookup it wraps is not + /// consistent across platforms: on iOS `containerURL(forSecurityApplicationGroupIdentifier:)` + /// returns nil when the app lacks the entitlement, but on macOS — where this package's tests + /// run — it returns a constructed path for any identifier, provisioned or not. The nil branch is + /// therefore unreachable from a test that passes a bogus group name, and the seam is what makes + /// the fallback testable at all. + public static func resolved( + appGroup: String = StoreLocation.appGroup, + legacyDirectory: URL = .applicationSupportDirectory, + containerURL: (String) -> URL? = { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0) + } + ) -> StoreLocation { + guard let container = containerURL(appGroup) else { + return StoreLocation( + directory: legacyDirectory, + legacyDirectory: legacyDirectory, + isShared: false + ) + } + + // The store goes in the container root rather than a subdirectory. The root is guaranteed to + // exist once the container resolves, which removes a create-directory step — and its failure + // mode — from the migration path. The file names are already owner-scoped and prefixed, so + // they cannot collide with `ChatPreviews/`. + return StoreLocation( + directory: container, + legacyDirectory: legacyDirectory, + isShared: true + ) + } + + // MARK: - Files - + + /// The four files that make up one owner's store within a single directory. + /// + /// SQLite derives the `-wal` and `-shm` names from the main file rather than being told them, so + /// these are not independently choosable paths. They are named here because the migration and + /// `Database.deleteStore` both have to account for them. + public struct Files: Sendable, Equatable { + public let database: URL + public let wal: URL + public let shm: URL + public let version: URL + + public init(database: URL, wal: URL, shm: URL, version: URL) { + self.database = database + self.wal = wal + self.shm = shm + self.version = version + } + } + + /// The owner's store files in the current directory. + public func files(owner: PublicKey) -> Files { + Self.files(owner: owner, in: directory) + } + + /// The owner's store files in the pre-move directory. + public func legacyFiles(owner: PublicKey) -> Files { + Self.files(owner: owner, in: legacyDirectory) + } + + private static func files(owner: PublicKey, in directory: URL) -> Files { + let base = "flipcash-\(owner.base58)" + return Files( + database: directory.appendingPathComponent("\(base).sqlite"), + wal: directory.appendingPathComponent("\(base).sqlite-wal"), + shm: directory.appendingPathComponent("\(base).sqlite-shm"), + // No separator before "version", and no extension. This is the name a pre-move install + // already has on disk, so the migration has to look for exactly this to find it. + version: directory.appendingPathComponent("\(base)version") + ) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift new file mode 100644 index 000000000..58855dc8d --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/StoreLocationTests.swift @@ -0,0 +1,145 @@ +// +// StoreLocationTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("Store location") +struct StoreLocationTests { + + private let owner = try! PublicKey(Data(repeating: 7, count: 32)) + + private func location(shared: Bool = true) -> StoreLocation { + let root = FileManager.default.temporaryDirectory + return StoreLocation( + directory: root.appendingPathComponent("shared-\(UUID().uuidString)"), + legacyDirectory: root.appendingPathComponent("legacy-\(UUID().uuidString)"), + isShared: shared + ) + } + + // MARK: - File names - + + /// The names have to match what a pre-move install already wrote, or the migration looks in the + /// right directory for the wrong files and silently concludes there is nothing to move. + @Test func fileNamesFollowTheExistingConvention() { + let location = location() + let files = location.files(owner: owner) + let base = "flipcash-\(owner.base58)" + + #expect(files.database.lastPathComponent == "\(base).sqlite") + #expect(files.wal.lastPathComponent == "\(base).sqlite-wal") + #expect(files.shm.lastPathComponent == "\(base).sqlite-shm") + } + + /// The version file has no separator and no extension. It is the one name that looks like a typo + /// and is not — changing it would orphan every existing install's recorded schema version, which + /// reads as "version 0" and triggers a full store rebuild. + @Test func versionFileNameHasNoSeparator() { + let files = location().files(owner: owner) + + #expect(files.version.lastPathComponent == "flipcash-\(owner.base58)version") + #expect(files.version.pathExtension.isEmpty) + } + + @Test func filesLandInTheCurrentDirectory() { + let location = location() + let files = location.files(owner: owner) + + #expect(files.database.deletingLastPathComponent().path == location.directory.path) + #expect(files.version.deletingLastPathComponent().path == location.directory.path) + } + + @Test func legacyFilesLandInTheLegacyDirectory() { + let location = location() + let files = location.legacyFiles(owner: owner) + + #expect(files.database.deletingLastPathComponent().path == location.legacyDirectory.path) + #expect(files.version.deletingLastPathComponent().path == location.legacyDirectory.path) + } + + /// Same owner, same names, different directories — this is what makes the migration a move + /// rather than a rename. + @Test func currentAndLegacyDifferOnlyByDirectory() { + let location = location() + + #expect(location.files(owner: owner).database.lastPathComponent + == location.legacyFiles(owner: owner).database.lastPathComponent) + #expect(location.files(owner: owner).database.path + != location.legacyFiles(owner: owner).database.path) + } + + @Test func differentOwnersGetDifferentFiles() throws { + let location = location() + let other = try PublicKey(Data(repeating: 9, count: 32)) + + #expect(location.files(owner: owner).database.path != location.files(owner: other).database.path) + } + + // MARK: - Resolution - + + /// An unresolvable App Group must not be fatal. The app keeps working out of the legacy + /// directory; what it loses is the extension's ability to see the store. + @Test func missingContainerFallsBackToLegacy() { + let legacy = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-\(UUID().uuidString)") + + let location = StoreLocation.resolved( + legacyDirectory: legacy, + containerURL: { _ in nil } + ) + + #expect(location.isShared == false) + #expect(location.directory.path == legacy.path) + #expect(location.legacyDirectory.path == legacy.path) + } + + /// When the container is missing, current and legacy are the same directory — so a migration + /// asked to run finds its source and destination identical and has to treat that as a no-op + /// rather than moving a file onto itself. + @Test func fallbackMakesCurrentAndLegacyIdentical() { + let legacy = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-\(UUID().uuidString)") + + let location = StoreLocation.resolved( + legacyDirectory: legacy, + containerURL: { _ in nil } + ) + + #expect(location.files(owner: owner) == location.legacyFiles(owner: owner)) + } + + @Test func resolvedContainerBecomesTheStoreDirectory() { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent("container-\(UUID().uuidString)") + let legacy = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-\(UUID().uuidString)") + + let location = StoreLocation.resolved( + legacyDirectory: legacy, + containerURL: { _ in container } + ) + + #expect(location.isShared) + #expect(location.directory.path == container.path) + #expect(location.legacyDirectory.path == legacy.path) + } + + /// The group identifier is passed through untouched — a typo here would silently give the app a + /// container the extensions do not share. + @Test func resolvedPassesTheAppGroupToTheLookup() { + var requested: String? + _ = StoreLocation.resolved(containerURL: { group in + requested = group + return nil + }) + + #expect(requested == StoreLocation.appGroup) + #expect(StoreLocation.appGroup == "group.com.flipcash.shared") + } +} From 176a4fbdbb0256e349d23676f91979f4ed57642e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 19:14:25 -0400 Subject: [PATCH 2/4] 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. --- Flipcash/Core/Controllers/Database/Database.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Controllers/Database/Database.swift b/Flipcash/Core/Controllers/Database/Database.swift index 08075feb0..aee75181f 100644 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ b/Flipcash/Core/Controllers/Database/Database.swift @@ -195,7 +195,7 @@ nonisolated class Database: @unchecked Sendable { } static func setUserVersion(version: Int, owner: PublicKey) throws { - try! "\(version)".write( + try "\(version)".write( to: .versionFile(owner: owner), atomically: true, encoding: .utf8 From 629f9cea64f483a2eb506ca2b21bac6d1c762953 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 19:16:31 -0400 Subject: [PATCH 3/4] 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. --- .../Core/Controllers/Database/Database.swift | 40 ++--- .../Controllers/Database/StoreMigration.swift | 150 ++++++++++++++++++ .../Core/Session/SessionAuthenticator.swift | 61 +++++-- 3 files changed, 214 insertions(+), 37 deletions(-) create mode 100644 Flipcash/Core/Controllers/Database/StoreMigration.swift diff --git a/Flipcash/Core/Controllers/Database/Database.swift b/Flipcash/Core/Controllers/Database/Database.swift index aee75181f..90b154bf8 100644 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ b/Flipcash/Core/Controllers/Database/Database.swift @@ -179,12 +179,16 @@ nonisolated class Database: @unchecked Sendable { } // MARK: - Versioning - - - static func deleteStore(owner: PublicKey) throws { + + /// Removes the store and the write-ahead log files beside it. + /// + /// The version file is deliberately left alone: the caller deletes the store because the + /// recorded version is stale, and writes the new one immediately afterwards. + static func deleteStore(files: StoreLocation.Files) throws { let urlsToRemove: [URL] = [ - .dataStore(owner: owner), - .storeSHM(owner: owner), - .storeWAL(owner: owner), + files.database, + files.shm, + files.wal, ] try urlsToRemove.forEach { @@ -194,17 +198,17 @@ nonisolated class Database: @unchecked Sendable { } } - static func setUserVersion(version: Int, owner: PublicKey) throws { + static func setUserVersion(version: Int, files: StoreLocation.Files) throws { try "\(version)".write( - to: .versionFile(owner: owner), + to: files.version, atomically: true, encoding: .utf8 ) } - static func userVersion(owner: PublicKey) throws -> Int? { + static func userVersion(files: StoreLocation.Files) throws -> Int? { let versionString = try String( - contentsOf: .versionFile(owner: owner), + contentsOf: files.version, encoding: .utf8 ) @@ -212,24 +216,6 @@ nonisolated class Database: @unchecked Sendable { } } -nonisolated extension URL { - static func dataStore(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite") - } - - static func storeWAL(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite-wal") - } - - static func storeSHM(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite-shm") - } - - static func versionFile(owner: PublicKey) -> URL { - URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58)version") - } -} - nonisolated extension Notification.Name { static let databaseDidChange = Notification.Name("databaseDidChange") } diff --git a/Flipcash/Core/Controllers/Database/StoreMigration.swift b/Flipcash/Core/Controllers/Database/StoreMigration.swift new file mode 100644 index 000000000..986d0bc09 --- /dev/null +++ b/Flipcash/Core/Controllers/Database/StoreMigration.swift @@ -0,0 +1,150 @@ +// +// StoreMigration.swift +// Code +// + +import Foundation +import FlipcashCore +import SQLite + +nonisolated private let logger = Logger(label: "flipcash.database.migration") + +/// Moves an existing store out of the app's private Application Support directory and into the App +/// Group container, once, on the first launch of a build that knows about the shared location. +/// +/// Two properties matter more than speed here. It has to survive being interrupted partway through — +/// the process can be killed between any two file operations — and it has to survive a user who +/// never launches the old build again, which is why nothing is left behind for a later pass to +/// finish. +nonisolated enum StoreMigration { + + enum Outcome: Equatable { + /// Nothing to do: no store in either location, or the two locations are the same directory. + case notNeeded + /// A store was already at the shared location. Any legacy remnants were swept. + case alreadyMigrated + /// A legacy store was checkpointed and moved. + case migrated + /// The move failed. Anything this migration had put at the destination was removed, so the + /// caller opens a fresh store rather than a half-moved one. + case failed(String) + } + + /// Migrates the owner's store into `location.directory` if it is not already there. + /// + /// Never throws. A migration that cannot complete degrades to a fresh store, which the app + /// re-syncs; throwing here would instead block login on a file-system problem. + static func migrateIfNeeded( + owner: PublicKey, + location: StoreLocation, + fileManager: FileManager = .default + ) -> Outcome { + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + + // The App Group container was unavailable, so `resolved` fell back and both sides name the + // same paths. Moving a file onto itself fails; there is also nothing to move. + guard current.database != legacy.database else { + return .notNeeded + } + + let destinationExists = fileManager.fileExists(atPath: current.database.path) + let legacyExists = fileManager.fileExists(atPath: legacy.database.path) + + guard destinationExists || legacyExists else { + return .notNeeded + } + + do { + if destinationExists { + // Either a finished migration or one that stopped after the database landed. The + // destination store is authoritative either way; the legacy copy is stale from the + // moment the first write goes to the new one, so it is removed rather than merged. + try adoptVersionFile(from: legacy, to: current, fileManager: fileManager) + sweep(legacy, fileManager: fileManager) + return .alreadyMigrated + } + + // Fold the write-ahead log into the main database file first. After this the store is a + // single file, which is the only shape that survives a half-completed move: moving a + // database and its `-wal` as two operations can leave the pair split across directories, + // and SQLite reads that as a database with no log rather than as an error. + try checkpoint(at: legacy.database) + + // The version file moves before the database, and the order is the resumable one. If the + // process dies between the two, the next launch sees no database at the destination, + // retries, and finds the version file already there — which `adoptVersionFile` treats as + // done. Moving the database first would instead leave a store at the destination with no + // recorded schema version, which reads as version 0 and triggers a full rebuild. + try adoptVersionFile(from: legacy, to: current, fileManager: fileManager) + try fileManager.moveItem(at: legacy.database, to: current.database) + + sweep(legacy, fileManager: fileManager) + return .migrated + + } catch { + // Only remove what this migration could have created. When the destination store already + // existed on entry it holds real data, and clearing it would be the migration destroying + // the thing it was meant to preserve. + if !destinationExists { + discard(current, fileManager: fileManager) + } + + logger.error( + "Store migration failed", + metadata: ["error": "\(error)", "destinationExisted": "\(destinationExists)"] + ) + return .failed("\(error)") + } + } + + // MARK: - Steps - + + /// Runs a truncating checkpoint against the legacy store and closes it again. + /// + /// The connection is scoped to this function on purpose: SQLite.swift releases its handle from + /// `deinit`, so the store is only closed — and therefore only safe to move — once this returns. + private static func checkpoint(at url: URL) throws { + let connection = try Connection(url.path) + connection.busyTimeout = 2 + try connection.run("PRAGMA wal_checkpoint(TRUNCATE);") + } + + /// Moves the version file to the destination, tolerating a source that is already gone. + /// + /// A missing destination version reads as version 0, which makes the app delete the store it + /// just migrated and rebuild it — so this is not best-effort, unlike the `-wal`/`-shm` sweep. + private static func adoptVersionFile( + from legacy: StoreLocation.Files, + to current: StoreLocation.Files, + fileManager: FileManager + ) throws { + guard !fileManager.fileExists(atPath: current.version.path) else { + // Already moved, by this migration's earlier interrupted run. + return + } + guard fileManager.fileExists(atPath: legacy.version.path) else { + // No recorded version anywhere. The caller's version check writes one. + return + } + try fileManager.moveItem(at: legacy.version, to: current.version) + } + + /// Removes what the legacy location has left over. Best-effort by design: the store has already + /// moved, so a file that cannot be deleted costs disk space rather than correctness. + /// + /// The `-wal` and `-shm` are not moved. The checkpoint folded the log into the database, and + /// `-shm` is scratch that SQLite rebuilds, so carrying either across would only risk pairing a + /// stale log with a migrated database. + private static func sweep(_ legacy: StoreLocation.Files, fileManager: FileManager) { + for url in [legacy.database, legacy.wal, legacy.shm, legacy.version] { + try? fileManager.removeItem(at: url) + } + } + + private static func discard(_ current: StoreLocation.Files, fileManager: FileManager) { + for url in [current.database, current.wal, current.shm, current.version] { + try? fileManager.removeItem(at: url) + } + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 17114cb37..ed120ca9c 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -293,30 +293,71 @@ final class SessionAuthenticator { // MARK: - Database - private func initializeDatabase(owner: PublicKey) throws -> Database { - try createApplicationSupportIfNeeded() - + // Resolved once. Two calls could in principle disagree — the container lookup is a + // system call, not a constant — and a migration that reads one directory while the + // store opens from another is the failure this avoids. + let location = StoreLocation.resolved() + let files = location.files(owner: owner) + + if !location.isShared { + // The store still works; the notification extension cannot see it, so anything the + // extension prefetches is invisible to the app until this is fixed. That is an + // entitlement or provisioning problem, and it is silent without this. + ErrorReporting.captureError( + StoreError.appGroupUnavailable, + reason: "App Group container unavailable, database fell back to Application Support" + ) + } + + try createStoreDirectoryIfNeeded(at: location.directory) + + switch StoreMigration.migrateIfNeeded(owner: owner, location: location) { + case .notNeeded, .alreadyMigrated: + break + case .migrated: + logger.info("Migrated the database into the App Group container.") + case .failed(let description): + // The migration cleaned up after itself, so what follows opens a fresh store and + // sync repopulates it. Worth reporting because the user pays for it in a full + // re-sync, and because it means the legacy store is still on disk. + ErrorReporting.captureError( + StoreError.migrationFailed(description), + reason: "Database migration to the App Group container failed" + ) + } + // Currently we don't do migrations so every time // the user version is outdated, we'll rebuild the // database during sync. - let userVersion = (try? Database.userVersion(owner: owner)) ?? 0 + let userVersion = (try? Database.userVersion(files: files)) ?? 0 let currentVersion = try InfoPlist.value(for: "SQLiteVersion").integer() if currentVersion > userVersion { - try Database.deleteStore(owner: owner) + try Database.deleteStore(files: files) logger.error("Outdated user version, deleted database.") - try Database.setUserVersion(version: currentVersion, owner: owner) + try Database.setUserVersion(version: currentVersion, files: files) } - return try Database(url: .dataStore(owner: owner)) + return try Database(url: files.database) } - private func createApplicationSupportIfNeeded() throws { - if !FileManager.default.fileExists(atPath: URL.applicationSupportDirectory.path) { + /// Creates the store's directory when it is missing. + /// + /// `withIntermediateDirectories: true` where the old Application Support version passed + /// `false`: the App Group container root already exists once it resolves, so the call is + /// usually a no-op, and `true` also makes it succeed rather than throw in that case. + private func createStoreDirectoryIfNeeded(at directory: URL) throws { + if !FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.createDirectory( - at: .applicationSupportDirectory, - withIntermediateDirectories: false + at: directory, + withIntermediateDirectories: true ) } } + + private enum StoreError: Error { + case appGroupUnavailable + case migrationFailed(String) + } // MARK: - Login - From be2c7a38ce40045b6db02fecf86dc1cda757c5c9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 19:48:55 -0400 Subject: [PATCH 4/4] test(database): cover the move into the App Group container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- FlipcashTests/StoreMigrationTests.swift | 250 ++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 FlipcashTests/StoreMigrationTests.swift diff --git a/FlipcashTests/StoreMigrationTests.swift b/FlipcashTests/StoreMigrationTests.swift new file mode 100644 index 000000000..3a4ce53ea --- /dev/null +++ b/FlipcashTests/StoreMigrationTests.swift @@ -0,0 +1,250 @@ +// +// StoreMigrationTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SQLite +import FlipcashCore +@testable import Flipcash + +@Suite("Store migration") +struct StoreMigrationTests { + + private let owner = try! PublicKey(Data(repeating: 7, count: 32)) + + /// A location whose two directories are both fresh and both empty, standing in for an App + /// Group container and an Application Support directory. + private func makeLocation() throws -> StoreLocation { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("migration-\(UUID().uuidString)") + let current = root.appendingPathComponent("group") + let legacy = root.appendingPathComponent("support") + try FileManager.default.createDirectory(at: current, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true) + return StoreLocation(directory: current, legacyDirectory: legacy, isShared: true) + } + + /// Writes a store at `url` with one row in it, and closes it again. + private func seedStore(at url: URL, value: String) throws { + let connection = try Connection(url.path) + try connection.run("PRAGMA journal_mode = WAL;") + try connection.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try connection.run("INSERT INTO probe (value) VALUES (?);", value) + } + + /// Read-write on purpose. A migrated store arrives as a lone `.sqlite` — the `-wal` was + /// folded in and the `-shm` swept — and 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` does not hit this: it opens its writer before + /// its reader, and the writer creates the `-shm`. + private func readProbe(at url: URL) throws -> String? { + let connection = try Connection(url.path) + return try connection.scalar("SELECT value FROM probe LIMIT 1;") as? String + } + + private func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + // MARK: - Migrating - + + @Test("a store in the old location moves, with its rows") + func legacyStoreMoves() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "carried-over") + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .migrated) + #expect(exists(current.database)) + #expect(try readProbe(at: current.database) == "carried-over") + } + + @Test("the app opens the migrated store through Database and reads it") + func migratedStoreOpensThroughDatabase() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "upgrade-path") + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .migrated) + + // Through the type the app actually uses, including its `createTablesIfNeeded` pass, + // which is the step that would fail against a half-moved or truncated file. It also + // covers the writer-before-reader ordering in `Database.init`: a freshly migrated store + // has no `-shm`, and only a writer can create one. + let database = try Database(url: current.database) + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "upgrade-path") + } + + @Test("nothing is left behind in the old location") + func legacyLeftoversAreSwept() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + try seedStore(at: legacy.database, value: "moved") + try "4".write(to: legacy.version, atomically: true, encoding: .utf8) + + _ = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(!exists(legacy.database)) + #expect(!exists(legacy.wal)) + #expect(!exists(legacy.shm)) + #expect(!exists(legacy.version)) + } + + /// The store is deleted and rebuilt whenever the recorded version is older than the build's, + /// and a missing version file reads as 0. A migration that dropped the version file would + /// therefore wipe the store it had just moved. + @Test("the recorded version survives the move") + func versionFileMovesWithTheStore() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "row") + try Database.setUserVersion(version: 9, files: legacy) + + _ = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(try Database.userVersion(files: current) == 9) + } + + @Test("a store with no recorded version still moves") + func missingVersionFileIsNotAnError() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "unversioned") + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .migrated) + #expect(try readProbe(at: current.database) == "unversioned") + } + + /// Rows sitting in the write-ahead log rather than the main database file are the reason the + /// migration checkpoints before it moves anything. + @Test("rows still in the write-ahead log arrive at the destination") + func walContentsAreFoldedInBeforeTheMove() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + + // Held open across the migration: SQLite folds the log back in when the last connection + // to a store closes, so only a connection that is still open leaves a log on disk for + // the migration to find. + var writer: Connection? = try Connection(legacy.database.path) + try writer?.run("PRAGMA journal_mode = WAL;") + try writer?.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try writer?.run("INSERT INTO probe (value) VALUES (?);", "in-the-log") + #expect(exists(legacy.wal)) + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + #expect(outcome == .migrated) + + // Dropped before the read, which is the only reason this reads back at all. The + // migration swept the legacy `-wal` and `-shm` out from under this connection, and the + // moved file is the same inode it still points at, so a second connection opened + // alongside it inherits that half-torn state and throws a disk I/O error. The app never + // reaches this shape: it migrates at launch, before anything has the store open. + writer = nil + + #expect(try readProbe(at: current.database) == "in-the-log") + } + + // MARK: - Not migrating - + + @Test("no store in either place is nothing to do") + func emptyDirectoriesAreNotNeeded() throws { + let location = try makeLocation() + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .notNeeded) + } + + /// When the App Group container does not resolve, `StoreLocation` falls back and both + /// directories name the same paths. Moving a file onto itself fails. + @Test("a fallback location has nothing to move") + func identicalDirectoriesAreNotNeeded() throws { + let base = try makeLocation().legacyDirectory + let location = StoreLocation(directory: base, legacyDirectory: base, isShared: false) + try seedStore(at: location.files(owner: owner).database, value: "in-place") + + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .notNeeded) + #expect(try readProbe(at: location.files(owner: owner).database) == "in-place") + } + + @Test("a store already at the destination is kept, and the old one discarded") + func destinationStoreWins() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: current.database, value: "current") + try seedStore(at: legacy.database, value: "stale") + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .alreadyMigrated) + #expect(try readProbe(at: current.database) == "current") + #expect(!exists(legacy.database)) + } + + /// The half-completed move the ordering is designed around: the version file landed, the + /// process died, the database is still in the old directory. + @Test("a move interrupted after the version file finishes on the next run") + func interruptedMoveResumes() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "resumed") + try Database.setUserVersion(version: 3, files: current) + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .migrated) + #expect(try readProbe(at: current.database) == "resumed") + #expect(try Database.userVersion(files: current) == 3) + } + + /// A destination version file is the record of a move that already got that far, so a legacy + /// one left beside it is stale rather than authoritative. + @Test("a version file already at the destination is not overwritten by the old one") + func destinationVersionFileIsAuthoritative() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try seedStore(at: legacy.database, value: "row") + try Database.setUserVersion(version: 3, files: current) + try Database.setUserVersion(version: 1, files: legacy) + + _ = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(try Database.userVersion(files: current) == 3) + #expect(!exists(legacy.version)) + } + + // MARK: - Failing - + + @Test("an unreadable legacy store fails without leaving a partial one behind") + func corruptLegacyStoreDegrades() throws { + let location = try makeLocation() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + try Data("not a database".utf8).write(to: legacy.database) + try Database.setUserVersion(version: 5, files: legacy) + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + guard case .failed = outcome else { + Issue.record("expected a failure, got \(outcome)") + return + } + + // Nothing at the destination, so login opens a fresh store rather than one that is + // half of something else. + #expect(!exists(current.database)) + #expect(!exists(current.version)) + } +} +