diff --git a/Flipcash/Core/Controllers/Database/Database.swift b/Flipcash/Core/Controllers/Database/Database.swift index 08075feb0..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 { - try! "\(version)".write( - to: .versionFile(owner: owner), + static func setUserVersion(version: Int, files: StoreLocation.Files) throws { + try "\(version)".write( + 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 - 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") + } +} 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)) + } +} +