luminate/Sources/LuminateStore/Migrations/SchemaMigrator.swift

117 lines
4.5 KiB
Swift

//
// SchemaMigrator.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import LuminateCore
/// Applies the append-only schema migration history to a Luminate database.
package enum SchemaMigrator {
/// `PRAGMA application_id` marking a file as Luminate's: ASCII "LUMN" as a big-endian Int32.
package static let applicationID: Int32 = 0x4C55_4D4E
/// Every migration ever shipped, in ascending version order.
package static let migrations: [StoreMigration] = [
StoreMigration(
version: 1,
name: "create preference table",
minimumReaderVersion: 1,
statements: [
"""
CREATE TABLE schema_metadata (
id INTEGER PRIMARY KEY CHECK (id = 1),
minimum_reader_version INTEGER NOT NULL
) STRICT;
""",
"""
CREATE TABLE preference (
key TEXT PRIMARY KEY NOT NULL,
value ANY
) STRICT;
""",
]
)
]
/// The newest schema version shipped by this application.
package static var latestVersion: Int32 { migrations.last?.version ?? 0 }
/// Migrates `database` and validates its application and downgrade metadata.
///
/// - Parameter database: An open SQLite connection.
/// - Throws: A ``PreferenceStoreError`` when the file belongs to another application or requires
/// a newer reader.
package static func migrate(
_ database: SQLiteDatabase,
migrations: [StoreMigration] = SchemaMigrator.migrations
) throws {
var previous: Int32 = 0
for migration in migrations {
guard migration.version > previous else {
throw PreferenceStoreError.malformedMigrationList(version: migration.version)
}
previous = migration.version
}
let latest = migrations.last?.version ?? 0
let applicationID = try database.applicationID
guard applicationID == 0 || applicationID == Self.applicationID else {
throw PreferenceStoreError.notALuminateDatabase(applicationID: applicationID)
}
let current = try database.userVersion
if current > 0 {
let minimumReaderVersion = try readMinimumReaderVersion(from: database)
guard minimumReaderVersion <= latest else {
throw PreferenceStoreError.requiresNewerApplication(
minimumReaderVersion: minimumReaderVersion,
supportedVersion: latest
)
}
}
for migration in migrations where migration.version > current {
try database.transaction {
for statement in migration.statements {
try database.exec(statement)
}
try database.exec(
"INSERT INTO schema_metadata (id, minimum_reader_version) VALUES (1, "
+ "\(migration.minimumReaderVersion)) ON CONFLICT(id) DO UPDATE SET "
+ "minimum_reader_version = excluded.minimum_reader_version;"
)
try database.setUserVersion(migration.version)
}
}
if applicationID == 0 {
try database.setApplicationID(Self.applicationID)
}
}
private static func readMinimumReaderVersion(from database: SQLiteDatabase) throws -> Int32 {
let statement = try SQLiteStatement(
database: database,
sql: "SELECT minimum_reader_version FROM schema_metadata WHERE id = 1;"
)
guard try statement.step() else {
throw PreferenceStoreError.sqlite(message: database.errorMessage)
}
return statement.integer(at: 0)
}
}