Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 14 additions & 28 deletions Flipcash/Core/Controllers/Database/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -194,42 +198,24 @@ 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
)

return Int(versionString.trimmingCharacters(in: .whitespacesAndNewlines))
}
}

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")
}
Expand Down
150 changes: 150 additions & 0 deletions Flipcash/Core/Controllers/Database/StoreMigration.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
61 changes: 51 additions & 10 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 -

Expand Down
Loading