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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes are documented in this file. Gridnote follows [Semantic Versioning](https://semver.org/) for public releases where practical.

## [1.0.1] - 2026-07-16

### Fixed

- Moved the production SwiftData database from the shared `Application Support/default.store` path to the app-specific `Application Support/com.gridnote.app/Gridnote.store` path.
- Added a one-time, read-only SQLite snapshot migration for compatible v1.0.0 stores, with schema and integrity validation before activation.
- Prevented unit-test host launches from opening or modifying the production database.
- Added regression coverage for production-path isolation, test-host isolation, directory creation, and legacy-book migration.

### Upgrade Notes

- Existing v1.0.0 data is migrated automatically when its legacy Gridnote schema remains readable.
- Migration failures stop initialization instead of silently replacing the library with a new empty store.

## [1.0.0] - 2026-07-15

### Release
Expand Down Expand Up @@ -132,3 +146,4 @@ All notable changes are documented in this file. Gridnote follows [Semantic Vers
[0.1.7]: https://github.com/ZOB2077/Gridnote/releases/tag/v0.1.7
[0.1.8]: https://github.com/ZOB2077/Gridnote/releases/tag/v0.1.8
[1.0.0]: https://github.com/ZOB2077/Gridnote/releases/tag/v1.0.0
[1.0.1]: https://github.com/ZOB2077/Gridnote/releases/tag/v1.0.1
4 changes: 2 additions & 2 deletions Gridnote.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,8 @@
/* Begin XCBuildConfiguration section */
B10000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 6.0; }; name = Debug; };
B10000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 6.0; }; name = Release; };
B10000000000000000000003 /* Gridnote Debug */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 10; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Debug; };
B10000000000000000000004 /* Gridnote Release */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 10; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Release; };
B10000000000000000000003 /* Gridnote Debug */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 11; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Debug; };
B10000000000000000000004 /* Gridnote Release */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 11; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Release; };
B10000000000000000000005 /* GridnoteTests Debug */ = {isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gridnote.app/Contents/MacOS/DataHub"; }; name = Debug; };
B10000000000000000000006 /* GridnoteTests Release */ = {isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gridnote.app/Contents/MacOS/DataHub"; }; name = Release; };
B10000000000000000000007 /* GridnoteUITests Debug */ = {isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Gridnote; }; name = Debug; };
Expand Down
243 changes: 234 additions & 9 deletions Gridnote/Persistence/Model/GridnoteModelContainer.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import Foundation
import SQLite3
import SwiftData

enum GridnoteModelContainer {
private static let applicationSupportSubdirectory = "com.gridnote.app"
private static let storeFilename = "Gridnote.store"

static var schema: Schema {
Schema([
BookRecord.self,
Expand All @@ -15,18 +19,239 @@ enum GridnoteModelContainer {
}

static func make(inMemory: Bool = false) throws -> ModelContainer {
let configuration: ModelConfiguration
if let testStorePath = ProcessInfo.processInfo.environment["GRIDNOTE_TEST_STORE_PATH"] {
configuration = ModelConfiguration(
schema: schema,
url: URL(fileURLWithPath: testStorePath)
)
} else {
configuration = ModelConfiguration(
let environment = ProcessInfo.processInfo.environment
if let testStorePath = environment["GRIDNOTE_TEST_STORE_PATH"] {
return try make(storeURL: URL(fileURLWithPath: testStorePath))
}

if shouldUseInMemoryStore(requested: inMemory, environment: environment) {
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: inMemory
isStoredInMemoryOnly: true
)
return try ModelContainer(for: schema, configurations: [configuration])
}

let applicationSupportDirectory = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let storeURL = productionStoreURL(applicationSupportDirectory: applicationSupportDirectory)
try migrateLegacyStoreIfNeeded(
legacyStoreURL: applicationSupportDirectory.appendingPathComponent("default.store"),
destinationStoreURL: storeURL
)
return try make(storeURL: storeURL)
}

static func make(storeURL: URL) throws -> ModelContainer {
try FileManager.default.createDirectory(
at: storeURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let configuration = ModelConfiguration(schema: schema, url: storeURL)
return try ModelContainer(for: schema, configurations: [configuration])
}

static func productionStoreURL(applicationSupportDirectory: URL) -> URL {
applicationSupportDirectory
.appendingPathComponent(applicationSupportSubdirectory, isDirectory: true)
.appendingPathComponent(storeFilename, isDirectory: false)
}

static func shouldUseInMemoryStore(
requested: Bool,
environment: [String: String]
) -> Bool {
requested || environment["XCTestConfigurationFilePath"] != nil
}

static func migrateLegacyStoreIfNeeded(
legacyStoreURL: URL,
destinationStoreURL: URL,
fileManager: FileManager = .default
) throws {
guard !fileManager.fileExists(atPath: destinationStoreURL.path),
fileManager.fileExists(atPath: legacyStoreURL.path),
try sqliteStoreContainsGridnoteSchema(at: legacyStoreURL) else {
return
}

try fileManager.createDirectory(
at: destinationStoreURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let temporaryURL = legacyStoreURL
.deletingLastPathComponent()
.appendingPathComponent("Gridnote-migration-\(UUID().uuidString).store")
defer { try? fileManager.removeItem(at: temporaryURL) }

try backupSQLiteStore(from: legacyStoreURL, to: temporaryURL)
try fileManager.moveItem(at: temporaryURL, to: destinationStoreURL)
}

private static func sqliteStoreContainsGridnoteSchema(at url: URL) throws -> Bool {
try withSQLiteDatabase(at: url, flags: SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX) { database in
try queryReturnsRow(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ZBOOKRECORD' LIMIT 1",
database: database,
storeURL: url
)
}
}

private static func sqliteDatabasePassesIntegrityCheck(
_ database: OpaquePointer,
storeURL: URL
) throws -> Bool {
var statement: OpaquePointer?
let prepareResult = sqlite3_prepare_v2(database, "PRAGMA integrity_check", -1, &statement, nil)
guard prepareResult == SQLITE_OK, let statement else {
throw sqliteError(database: database, code: prepareResult, storeURL: storeURL)
}
defer { sqlite3_finalize(statement) }

guard sqlite3_step(statement) == SQLITE_ROW,
let result = sqlite3_column_text(statement, 0) else {
return false
}
return String(cString: result) == "ok"
}

private static func queryReturnsRow(
_ sql: String,
database: OpaquePointer,
storeURL: URL
) throws -> Bool {
var statement: OpaquePointer?
let prepareResult = sqlite3_prepare_v2(database, sql, -1, &statement, nil)
guard prepareResult == SQLITE_OK, let statement else {
throw sqliteError(database: database, code: prepareResult, storeURL: storeURL)
}
defer { sqlite3_finalize(statement) }

let stepResult = sqlite3_step(statement)
switch stepResult {
case SQLITE_ROW:
return true
case SQLITE_DONE:
return false
default:
throw sqliteError(database: database, code: stepResult, storeURL: storeURL)
}
}

private static func backupSQLiteStore(from sourceURL: URL, to destinationURL: URL) throws {
try withSQLiteDatabase(
at: sourceURL,
flags: SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX
) { sourceDatabase in
try withInMemorySQLiteDatabase { destinationDatabase in
guard let backup = sqlite3_backup_init(destinationDatabase, "main", sourceDatabase, "main") else {
throw sqliteError(
database: destinationDatabase,
code: sqlite3_errcode(destinationDatabase),
storeURL: destinationURL
)
}

var busyRetries = 0
var stepResult = SQLITE_OK
repeat {
stepResult = sqlite3_backup_step(backup, 128)
if stepResult == SQLITE_BUSY || stepResult == SQLITE_LOCKED {
busyRetries += 1
guard busyRetries <= 100 else { break }
sqlite3_sleep(50)
}
} while stepResult == SQLITE_OK || stepResult == SQLITE_BUSY || stepResult == SQLITE_LOCKED

let finishResult = sqlite3_backup_finish(backup)
guard stepResult == SQLITE_DONE, finishResult == SQLITE_OK else {
let errorCode = finishResult == SQLITE_OK ? stepResult : finishResult
throw sqliteError(
database: destinationDatabase,
code: errorCode,
storeURL: destinationURL
)
}

guard try queryReturnsRow(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ZBOOKRECORD' LIMIT 1",
database: destinationDatabase,
storeURL: destinationURL
), try sqliteDatabasePassesIntegrityCheck(
destinationDatabase,
storeURL: destinationURL
) else {
throw storageError("The legacy Gridnote store could not be validated after migration.")
}

var byteCount: sqlite3_int64 = 0
guard let bytes = sqlite3_serialize(destinationDatabase, "main", &byteCount, 0),
byteCount > 0,
byteCount <= 512 * 1_024 * 1_024 else {
throw storageError("The migrated Gridnote store has an invalid size.")
}
defer { sqlite3_free(bytes) }
try Data(bytes: bytes, count: Int(byteCount)).write(to: destinationURL, options: .atomic)
}
}
}

private static func withInMemorySQLiteDatabase<T>(
operation: (OpaquePointer) throws -> T
) throws -> T {
var database: OpaquePointer?
let openResult = sqlite3_open(":memory:", &database)
guard openResult == SQLITE_OK, let database else {
defer { if let database { sqlite3_close(database) } }
throw sqliteError(
database: database,
code: openResult,
storeURL: URL(fileURLWithPath: ":memory:")
)
}
defer { sqlite3_close(database) }
return try operation(database)
}

private static func withSQLiteDatabase<T>(
at url: URL,
flags: Int32,
operation: (OpaquePointer) throws -> T
) throws -> T {
var database: OpaquePointer?
let openResult = sqlite3_open_v2(url.path, &database, flags, nil)
guard openResult == SQLITE_OK, let database else {
defer { if let database { sqlite3_close(database) } }
throw sqliteError(database: database, code: openResult, storeURL: url)
}
defer { sqlite3_close(database) }
sqlite3_busy_timeout(database, 5_000)
return try operation(database)
}

private static func sqliteError(
database: OpaquePointer?,
code: Int32,
storeURL: URL
) -> Error {
let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown SQLite error"
let extendedCode = database.map(sqlite3_extended_errcode) ?? code
let systemError = database.map(sqlite3_system_errno) ?? 0
return storageError(
"SQLite error \(code)/\(extendedCode), errno \(systemError) at \(storeURL.path): \(message)"
)
}

private static func storageError(_ message: String) -> Error {
NSError(
domain: "com.gridnote.app.storage",
code: 1,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
69 changes: 69 additions & 0 deletions GridnoteTests/Persistence/PersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,75 @@ import XCTest
@testable import Gridnote

final class PersistenceTests: XCTestCase {
func testLegacyDefaultStoreMigratesIntoApplicationSpecificStore() throws {
let rootURL = FileManager.default.temporaryDirectory
.appendingPathComponent("gridnote-legacy-migration-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: rootURL) }
let legacyStoreURL = rootURL.appendingPathComponent("default.store", isDirectory: false)
let destinationStoreURL = rootURL
.appendingPathComponent("com.gridnote.app", isDirectory: true)
.appendingPathComponent("Gridnote.store", isDirectory: false)
var expectedBookID: UUID?

do {
let legacyContainer = try GridnoteModelContainer.make(storeURL: legacyStoreURL)
let repository = BookRepository(context: ModelContext(legacyContainer))
let book = try repository.insert(
metadata: BookMetadata(title: "Legacy Book", sourceFilename: "legacy.epub"),
sourcePath: "/tmp/legacy.epub",
format: .epub
)
expectedBookID = book.id
}

try GridnoteModelContainer.migrateLegacyStoreIfNeeded(
legacyStoreURL: legacyStoreURL,
destinationStoreURL: destinationStoreURL
)

let migratedContainer = try GridnoteModelContainer.make(storeURL: destinationStoreURL)
let migratedBook = try BookRepository(context: ModelContext(migratedContainer)).fetch(
id: try XCTUnwrap(expectedBookID)
)
XCTAssertEqual(migratedBook?.metadata.title, "Legacy Book")
}

func testUnitTestHostUsesInMemoryStoreWithoutAnExplicitFlag() {
XCTAssertTrue(
GridnoteModelContainer.shouldUseInMemoryStore(
requested: false,
environment: ["XCTestConfigurationFilePath": "/tmp/Gridnote.xctestconfiguration"]
)
)
}

func testProductionStoreURLIsIsolatedFromTheSharedSwiftDataDefault() {
let applicationSupportURL = URL(fileURLWithPath: "/tmp/Application Support", isDirectory: true)

let storeURL = GridnoteModelContainer.productionStoreURL(
applicationSupportDirectory: applicationSupportURL
)

XCTAssertEqual(
storeURL,
applicationSupportURL
.appendingPathComponent("com.gridnote.app", isDirectory: true)
.appendingPathComponent("Gridnote.store", isDirectory: false)
)
XCTAssertNotEqual(storeURL.lastPathComponent, "default.store")
}

func testPersistentStoreCreatesItsApplicationSpecificDirectory() throws {
let directoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("gridnote-persistence-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: directoryURL) }
let storeURL = directoryURL.appendingPathComponent("Gridnote.store", isDirectory: false)

_ = try GridnoteModelContainer.make(storeURL: storeURL)

XCTAssertTrue(FileManager.default.fileExists(atPath: directoryURL.path))
}

func testBookAliasAndProgressCanBeCreatedAndQueried() throws {
let container = try GridnoteModelContainer.make(inMemory: true)
let context = ModelContext(container)
Expand Down
Loading