117 lines
4.5 KiB
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)
|
|
}
|
|
}
|