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
2 changes: 1 addition & 1 deletion Sources/ICloudCLICore/LocalInventories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ public struct LocalSQLiteInventoryReader: Sendable {
}

private func withSnapshot<Result>(_ operation: (LocalSQLiteInventoryReader) throws -> Result) throws -> Result {
try SQLiteSnapshotQueryEngine(source: database).withSnapshot { snapshot, workspace in
try SQLiteSnapshotQueryEngine.production(source: database).withSnapshot { snapshot, workspace in
try operation(LocalSQLiteInventoryReader(database: snapshot, snapshotsLiveStores: false, reportedStore: reportedStore, snapshotWorkspace: workspace))
}
}
Expand Down
21 changes: 19 additions & 2 deletions Sources/ICloudCLICore/SQLiteSnapshotQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ public struct SQLiteSnapshotQueryEngine: Sendable {
self.reportedStore = reportedStore ?? source.path
}

public static func production(
source: URL,
timeout: TimeInterval = 10,
busyTimeoutMilliseconds: Int = 1_000,
temporaryRoot: URL = FileManager.default.temporaryDirectory,
reportedStore: String? = nil
) -> Self {
Self(
source: source,
timeout: max(5, timeout),
busyTimeoutMilliseconds: max(500, busyTimeoutMilliseconds),
temporaryRoot: temporaryRoot,
reportedStore: reportedStore
)
}

public func query<T: Decodable>(_ sql: String) throws -> [T] {
try withSnapshot { snapshot, directory in
try querySnapshot(snapshot, workspace: directory, sql: sql)
Expand Down Expand Up @@ -89,7 +105,8 @@ public struct SQLiteSnapshotQueryEngine: Sendable {
}

func withSnapshot<Result>(_ operation: (URL, URL) throws -> Result) throws -> Result {
guard FileManager.default.fileExists(atPath: source.path) else {
let resolvedSource = source.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: resolvedSource.path) else {
throw LocalInventoryError.missingStore(reportedStore)
}

Expand All @@ -107,7 +124,7 @@ public struct SQLiteSnapshotQueryEngine: Sendable {

let snapshot = directory.appendingPathComponent("snapshot.sqlite")
do {
try createSQLiteSnapshot(from: source, to: snapshot)
try createSQLiteSnapshot(from: resolvedSource, to: snapshot)
} catch let error as LocalInventoryError {
throw error
} catch {
Expand Down
38 changes: 38 additions & 0 deletions Tests/ICloudCLICoreTests/SQLiteSnapshotQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ import Testing

private struct SnapshotValueRow: Decodable, Equatable { let value: String }

@Test func productionSnapshotEngineEnforcesTimeoutFloors() {
let source = URL(fileURLWithPath: "/private/source.db")
let constrained = SQLiteSnapshotQueryEngine.production(
source: source,
timeout: 0.001,
busyTimeoutMilliseconds: 0
)
let relaxed = SQLiteSnapshotQueryEngine.production(
source: source,
timeout: 7,
busyTimeoutMilliseconds: 800
)

#expect(constrained.timeout == 5)
#expect(constrained.busyTimeoutMilliseconds == 500)
#expect(relaxed.timeout == 7)
#expect(relaxed.busyTimeoutMilliseconds == 800)
}

@Test func snapshotQueryReadsDatabaseWithoutCompanionFilesAndCleansUp() throws {
let root = try temporarySQLiteSnapshotDirectory(named: "no-companions")
defer { try? FileManager.default.removeItem(at: root) }
Expand Down Expand Up @@ -32,6 +51,25 @@ private struct SnapshotValueRow: Decodable, Equatable { let value: String }
#expect(rows == [SnapshotValueRow(value: "wal-row")])
}

@Test func snapshotQueryFollowsSymlinkedStoreWithCoherentWALSnapshot() throws {
let root = try temporarySQLiteSnapshotDirectory(named: "symlink-wal")
defer { try? FileManager.default.removeItem(at: root) }
let liveStore = root.appendingPathComponent("live/source.db")
try FileManager.default.createDirectory(at: liveStore.deletingLastPathComponent(), withIntermediateDirectories: true)
let writer = try openWALFixture(database: liveStore)
defer { writer.stop() }
let linkedStore = root.appendingPathComponent("linked-source.db")
try FileManager.default.createSymbolicLink(atPath: linkedStore.path, withDestinationPath: liveStore.path)

let engine = SQLiteSnapshotQueryEngine(source: linkedStore)
try engine.withSnapshot { snapshot, workspace in
#expect((try? FileManager.default.destinationOfSymbolicLink(atPath: snapshot.path)) == nil)
#expect(!FileManager.default.fileExists(atPath: snapshot.path + "-wal"))
let rows: [SnapshotValueRow] = try engine.querySnapshot(snapshot, workspace: workspace, sql: "SELECT value FROM values_table ORDER BY value;")
#expect(rows == [SnapshotValueRow(value: "wal-row")])
}
}

@Test func snapshotQueryCreatesSingleFileSnapshotForLiveWALStore() throws {
let root = try temporarySQLiteSnapshotDirectory(named: "coherent-wal")
defer { try? FileManager.default.removeItem(at: root) }
Expand Down
2 changes: 1 addition & 1 deletion docs/sqlite-snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Apple applications often keep committed SQLite state across a database file and its write-ahead log. Querying the live path directly can produce inconsistent reads, wait indefinitely on a busy store, or interact with an Apple-owned database in ways the CLI does not intend.

`SQLiteSnapshotQueryEngine` creates a mode-`0700` temporary directory and asks SQLite to create a consistent private snapshot with `VACUUM INTO`. SQLite coordinates the main database and WAL under one read view, so a live writer cannot leave a mixed-generation main/WAL copy. The resulting snapshot is mode-`0600` and queried with `-readonly`, `PRAGMA query_only=ON`, a one-second busy timeout, and a ten-second process timeout. The snapshot, query output, and error output are deleted before the call returns or throws. Snapshot contents are never logged.
`SQLiteSnapshotQueryEngine` creates a mode-`0700` temporary directory and asks SQLite to create a consistent private snapshot with `VACUUM INTO`. SQLite coordinates the main database and WAL under one read view, so a live writer cannot leave a mixed-generation main/WAL copy. The resulting snapshot is mode-`0600` and queried with `-readonly`, `PRAGMA query_only=ON`, a bounded busy timeout, and a bounded process timeout. Production callers use `SQLiteSnapshotQueryEngine.production`, which enforces minimums of 500ms busy timeout and 5s process timeout. The snapshot, query output, and error output are deleted before the call returns or throws. Snapshot contents are never logged.

Errors retain the original store path and distinguish missing stores, permission denial, unsupported schemas, locked or busy stores, and timeouts. `VACUUM INTO` reads the Apple-owned source without checkpointing, mutating, or creating source-side files.

Expand Down
Loading