Skip to content
Draft
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
24 changes: 6 additions & 18 deletions .github/workflows/build_and_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,13 @@ jobs:
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Build and Test

- name: Test on iOS simulator
run: |
xcodebuild test -scheme PowerSync-Package -destination "platform=iOS Simulator,name=iPhone 16"
xcodebuild test -scheme PowerSync-Package -destination "platform=macOS,arch=arm64,name=My Mac"
xcodebuild test -scheme PowerSync-Package -destination "platform=watchOS Simulator,arch=arm64,name=Apple Watch Ultra 2 (49mm)"

buildSwift6:
name: Build and test with Swift 6
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up XCode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Use Swift 6
- name: Test on macOS
run: |
sed -i '' 's|^// swift-tools-version:.*$|// swift-tools-version:6.1|' Package.swift
- name: Build and Test
xcodebuild test -scheme PowerSync-Package -destination "platform=macOS,arch=arm64,name=My Mac"
- name: Test on watchOS simulator
run: |
swift build -Xswiftc -strict-concurrency=complete
swift test -Xswiftc -strict-concurrency=complete
xcodebuild test -scheme PowerSync-Package -destination "platform=watchOS Simulator,arch=arm64,name=Apple Watch Ultra 2 (49mm)"

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 23 additions & 9 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.7
// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand All @@ -7,33 +7,43 @@ let packageName = "PowerSync"

// Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin
// build. Also see docs/LocalBuild.md for details
let localKotlinSdkOverride: String? = "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin/internal"

let localKotlinSdkOverride: String? = nil
// Set this to the absolute path of your powersync-sqlite-core checkout if you want to use a
// local build of the core extension.
let localCoreExtension: String? = nil

// Currently encryption is required for GRDB integration
let encryption = true

// Our target and dependency setup is different when a local Kotlin SDK is used. Without the local
// SDK, we have no package dependency on Kotlin and download the XCFramework from Kotlin releases as
// a binary target.
// With a local SDK, we point to a `Package.swift` within the Kotlin SDK containing a target pointing
// towards a local framework build
var conditionalDependencies: [Package.Dependency] = []
var conditionalDependencies: [Package.Dependency] = [
]

var conditionalTargets: [Target] = []
var kotlinTargetDependency = Target.Dependency.target(name: "PowerSyncKotlin")

if encryption {
conditionalDependencies.append(.package(url: "https://github.com/sqlcipher/SQLCipher.swift.git", from: "4.10.0"))
} else {
conditionalDependencies.append(.package(url: "https://github.com/sbooth/CSQLite.git", from: "3.50.4"))
}

if let kotlinSdkPath = localKotlinSdkOverride {
// We can't depend on local XCFrameworks outside of this project's root, so there's a Package.swift
// in the PowerSyncKotlin project pointing towards a local build.
conditionalDependencies.append(.package(path: "\(kotlinSdkPath)/PowerSyncKotlin"))
conditionalDependencies.append(.package(path: "\(kotlinSdkPath)/internal/PowerSyncKotlin"))

kotlinTargetDependency = .product(name: "PowerSyncKotlin", package: "PowerSyncKotlin")
} else {
// Not using a local build, so download from releases
conditionalTargets.append(.binaryTarget(
name: "PowerSyncKotlin",
url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.7.0/PowersyncKotlinRelease.zip",
checksum: "836ac106c26a184c10373c862745d9af195737ad01505bb965f197797aa88535"
url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.8.0/PowersyncKotlinRelease.zip",
checksum: "31ac7c5e11d747e11bceb0b34f30438d37033e700c621b0a468aa308d887587f"
))
}

Expand All @@ -45,7 +55,7 @@ if let corePath = localCoreExtension {
// Not using a local build, so download from releases
conditionalDependencies.append(.package(
url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git",
exact: "0.4.6"
exact: "0.4.8"
))
}

Expand Down Expand Up @@ -87,14 +97,18 @@ let package = Package(
name: packageName,
dependencies: [
kotlinTargetDependency,
.product(name: "PowerSyncSQLiteCore", package: corePackageName)
.product(name: "PowerSyncSQLiteCore", package: corePackageName),
encryption ?
.product(name: "SQLCipher", package: "SQLCipher.swift") :
.product(name: "CSQLite", package: "CSQLite")
]
),
.target(
name: "PowerSyncGRDB",
dependencies: [
.target(name: "PowerSync"),
.product(name: "GRDB", package: "GRDB.swift")

]
),
.testTarget(
Expand Down
3 changes: 2 additions & 1 deletion Sources/PowerSync/Kotlin/KotlinAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ enum KotlinAdapter {
return PowerSyncKotlin.RawTable(
name: table.name,
put: translateStatement(table.put),
delete: translateStatement(table.delete)
delete: translateStatement(table.delete),
clear: table.clear,
);
}

Expand Down
17 changes: 13 additions & 4 deletions Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SQLCipher
import Foundation
import PowerSyncKotlin

Expand Down Expand Up @@ -80,9 +81,10 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol,
try await kotlinDatabase.disconnect()
}

func disconnectAndClear(clearLocal: Bool = true) async throws {
func disconnectAndClear(clearLocal: Bool, soft: Bool) async throws {
try await kotlinDatabase.disconnectAndClear(
clearLocal: clearLocal
clearLocal: clearLocal,
soft: soft
)
}

Expand Down Expand Up @@ -391,11 +393,18 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol,
func openKotlinDBDefault(
schema: Schema,
dbFilename: String,
logger: DatabaseLogger
logger: DatabaseLogger,
initialStatements: [String] = []
) -> PowerSyncDatabaseProtocol {
let rc = sqlite3_initialize()
if rc != 0 {
fatalError("Call to sqlite3_initialize() failed with \(rc)")
}

let factory = sqlite3DatabaseFactory(initialStatements: initialStatements)
return KotlinPowerSyncDatabaseImpl(
kotlinDatabase: PowerSyncDatabase(
factory: PowerSyncKotlin.DatabaseDriverFactory(),
factory: factory,
schema: KotlinAdapter.Schema.toKotlin(schema),
dbFilename: dbFilename,
logger: logger.kLogger
Expand Down
6 changes: 4 additions & 2 deletions Sources/PowerSync/PowerSyncDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ public let DEFAULT_DB_FILENAME = "powersync.db"
public func PowerSyncDatabase(
schema: Schema,
dbFilename: String = DEFAULT_DB_FILENAME,
logger: (any LoggerProtocol) = DefaultLogger()
logger: (any LoggerProtocol) = DefaultLogger(),
initialStatements: [String] = []
) -> PowerSyncDatabaseProtocol {
return openKotlinDBDefault(
schema: schema,
dbFilename: dbFilename,
logger: DatabaseLogger(logger)
logger: DatabaseLogger(logger),
initialStatements: initialStatements
)
}

Expand Down
28 changes: 22 additions & 6 deletions Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,20 @@ public protocol PowerSyncDatabaseProtocol: Queries, Sendable {
func disconnect() async throws

/// Disconnect and clear the database.
/// Use this when logging out.
/// The database can still be queried after this is called, but the tables
/// would be empty.
///
/// - Parameter clearLocal: Set to false to preserve data in local-only tables. Defaults to `true`.
func disconnectAndClear(clearLocal: Bool) async throws
/// Clearing the database is useful when a user logs out, to ensure another user logging in later would not see
/// previous data.
///
/// The database can still be queried after this is called, but the tables would be empty.
///
/// To perserve data in local-only tables, set `clearLocal` to `false`.
///
/// A `soft` clear deletes publicly visible data, but keeps internal copies of data synced in the database. This
/// usually means that if the same user logs out and back in again, the first sync is very fast because all internal
/// data is still available. When a different user logs in, no old data would be visible at any point.
/// Using soft clears is recommended where it's not a security issue that old data could be reconstructed from
/// the database.
func disconnectAndClear(clearLocal: Bool, soft: Bool) async throws

/// Close the database, releasing resources.
/// Also disconnects any active connection.
Expand Down Expand Up @@ -290,7 +298,15 @@ public extension PowerSyncDatabaseProtocol {
}

func disconnectAndClear() async throws {
try await disconnectAndClear(clearLocal: true)
try await disconnectAndClear(clearLocal: true, soft: false)
}

func disconnectAndClear(clearLocal: Bool) async throws {
try await disconnectAndClear(clearLocal: clearLocal, soft: false)
}

func disconnectAndClear(soft: Bool) async throws {
try await disconnectAndClear(clearLocal: true, soft: soft)
}

func getCrudBatch(limit: Int32 = 100) async throws -> CrudBatch? {
Expand Down
6 changes: 5 additions & 1 deletion Sources/PowerSync/Protocol/Schema/RawTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ public struct RawTable: BaseTableProtocol {

/// The statement to run when the sync client has to delete a row.
public let delete: PendingStatement

/// An optional statement to run when the database is cleared.
public let clear: String?

public init(name: String, put: PendingStatement, delete: PendingStatement) {
public init(name: String, put: PendingStatement, delete: PendingStatement, clear: String? = nil) {
self.name = name
self.put = put
self.delete = delete
self.clear = clear
}
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ actor GRDBConnectionPool: SQLiteConnectionPoolProtocol {
try database.notifyChanges(in: Table(table))
}
}

if case .failure(let error) = result.blockResult {
throw error
}
}

func withAllConnections(
Expand Down
1 change: 1 addition & 0 deletions Tests/PowerSyncGRDBTests/BasicTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ final class GRDBTests: XCTestCase {
override func tearDown() async throws {
try await database.disconnectAndClear()
database = nil
try pool.close()
try await super.tearDown()
}

Expand Down
15 changes: 15 additions & 0 deletions Tests/PowerSyncTests/CrudTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,19 @@ final class CrudTests: XCTestCase {
let finalTx = try await database.getNextCrudTransaction()
XCTAssertEqual(finalTx!.crud.count, 15)
}

func testSoftClear() async throws {
try await database.execute(sql: "INSERT INTO users (id, name) VALUES (uuid(), ?)", parameters: ["test"]);
try await database.execute(sql: "INSERT INTO ps_buckets (name, last_applied_op) VALUES (?, ?)", parameters: ["bkt", 10])

// Doing a soft-clear should delete data but keep the bucket around.
try await database.disconnectAndClear(soft: true)
let entries = try await database.getAll("SELECT name FROM ps_buckets", mapper: { cursor in try cursor.getString(index: 0) })
XCTAssertEqual(entries.count, 1)

// Doing a default clear also deletes buckets.
try await database.disconnectAndClear();
let newEntries = try await database.getAll("SELECT name FROM ps_buckets", mapper: { cursor in try cursor.getString(index: 0) })
XCTAssertEqual(newEntries.count, 0)
}
}
67 changes: 67 additions & 0 deletions Tests/PowerSyncTests/EncryptionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
@testable import PowerSync
import XCTest


final class EncryptionTests: XCTestCase {

func testLinksSqlcipher() async throws {
let database = openKotlinDBDefault(
schema: Schema(),
dbFilename: ":memory:",
logger: DatabaseLogger(DefaultLogger())
)

let version = try await database.get("pragma cipher_version", mapper: {cursor in
try cursor.getString(index: 0)
});

XCTAssertEqual(version, "4.11.0 community")
try await database.close()
}

func testEncryption() async throws {
let database = openKotlinDBDefault(
schema: Schema(tables: [
Table(
name: "users",
columns: [
.text("name")
]
),
]),
dbFilename: "encrypted.db",
logger: DatabaseLogger(DefaultLogger()),
initialStatements: [
"pragma key = 'foobar'"
],
)

try await database.execute("INSERT INTO users (id, name) VALUES (uuid(), 'test')")
try await database.close()

let another = openKotlinDBDefault(
schema: Schema(tables: [
Table(
name: "users",
columns: [
.text("name")
]
),
]),
dbFilename: "encrypted.db",
logger: DatabaseLogger(DefaultLogger()),
initialStatements: [
"pragma key = 'wrong password'"
],
)

var hadError = false
do {
try await database.execute("DELETE FROM users")
} catch let error {
hadError = true
}

XCTAssertTrue(hadError)
}
}
Loading