├── .github └── workflows │ ├── build_and_test.yaml │ ├── docs.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── Demo ├── PowerSyncExample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── PowerSyncExample.xcscheme ├── PowerSyncExample │ ├── .ci │ │ └── pre_build.sh │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Components │ │ ├── AddListView.swift │ │ ├── AddTodoListView.swift │ │ ├── ListRow.swift │ │ ├── ListView.swift │ │ ├── TodoListRow.swift │ │ ├── TodoListView.swift │ │ └── WifiIcon.swift │ ├── Constants.swift │ ├── Debug.swift │ ├── ErrorText.swift │ ├── Navigation.swift │ ├── PowerSync │ │ ├── Lists.swift │ │ ├── Schema.swift │ │ ├── SupabaseConnector.swift │ │ ├── SupabaseRemoteStorage.swift │ │ ├── SystemManager.swift │ │ └── Todos.swift │ ├── PowerSyncExampleApp.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── RootView.swift │ ├── Screens │ │ ├── HomeScreen.swift │ │ ├── SignInScreen.swift │ │ ├── SignUpScreen.swift │ │ └── TodosScreen.swift │ └── _Secrets.swift └── README.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── PowerSync │ ├── Kotlin │ ├── DatabaseLogger.swift │ ├── KotlinAdapter.swift │ ├── KotlinPowerSyncDatabaseImpl.swift │ ├── KotlinTypes.swift │ ├── PowerSyncBackendConnectorAdapter.swift │ ├── SafeCastError.swift │ ├── TransactionCallback.swift │ ├── db │ │ ├── KotlinConnectionContext.swift │ │ ├── KotlinCrudBatch.swift │ │ ├── KotlinCrudEntry.swift │ │ ├── KotlinCrudTransaction.swift │ │ ├── KotlinJsonParam.swift │ │ └── KotlinSqlCursor.swift │ ├── sync │ │ ├── KotlinSyncStatus.swift │ │ └── KotlinSyncStatusData.swift │ └── wrapQueryCursor.swift │ ├── Logger.swift │ ├── PowerSyncCredentials.swift │ ├── PowerSyncDatabase.swift │ ├── Protocol │ ├── LoggerProtocol.swift │ ├── PowerSyncBackendConnector.swift │ ├── PowerSyncDatabaseProtocol.swift │ ├── PowerSyncError.swift │ ├── QueriesProtocol.swift │ ├── Schema │ │ ├── Column.swift │ │ ├── Index.swift │ │ ├── IndexedColumn.swift │ │ ├── Schema.swift │ │ └── Table.swift │ ├── db │ │ ├── ConnectionContext.swift │ │ ├── CrudBatch.swift │ │ ├── CrudEntry.swift │ │ ├── CrudTransaction.swift │ │ ├── JsonParam.swift │ │ ├── SqlCursor.swift │ │ └── Transaction.swift │ └── sync │ │ ├── BucketPriority.swift │ │ ├── DownloadProgress.swift │ │ ├── PriorityStatusEntry.swift │ │ └── SyncStatusData.swift │ └── attachments │ ├── Attachment.swift │ ├── AttachmentContext.swift │ ├── AttachmentQueue.swift │ ├── AttachmentService.swift │ ├── AttachmentTable.swift │ ├── FileManagerLocalStorage.swift │ ├── LocalStorage.swift │ ├── LockActor.swift │ ├── README.md │ ├── RemoteStorage.swift │ ├── SyncErrorHandler.swift │ ├── SyncingService.swift │ └── WatchedAttachmentItem.swift ├── Tests └── PowerSyncTests │ ├── AttachmentTests.swift │ ├── ConnectTests.swift │ ├── CrudTests.swift │ ├── Kotlin │ ├── KotlinPowerSyncDatabaseImplTests.swift │ ├── SqlCursorTests.swift │ └── TestLogger.swift │ ├── Schema │ ├── ColumnTests.swift │ ├── IndexTests.swift │ ├── IndexedColumnTests.swift │ ├── SchemaTests.swift │ └── TableTests.swift │ └── test-utils │ └── MockConnector.swift └── docs ├── LocalBuild.md └── Release.md /.github/workflows/build_and_test.yaml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | push: 5 | workflow_call: 6 | 7 | jobs: 8 | build: 9 | name: Build and test 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Build and Test 14 | run: | 15 | xcodebuild test -scheme PowerSync -destination "platform=iOS Simulator,name=iPhone 15" 16 | xcodebuild test -scheme PowerSync -destination "platform=macOS,arch=arm64,name=My Mac" 17 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | 3 | on: 4 | push: 5 | 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: macos-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Build Docs 18 | run: | 19 | xcodebuild docbuild \ 20 | -scheme PowerSync \ 21 | -destination 'platform=macOS' \ 22 | -derivedDataPath ./DerivedData \ 23 | -configuration Release 24 | 25 | - name: Process Docs 26 | run: | 27 | xcrun docc process-archive \ 28 | transform-for-static-hosting \ 29 | ./DerivedData/Build/Products/Release/PowerSync.doccarchive/ \ 30 | --output-path ./docs-site \ 31 | --hosting-base-path /powersync-swift 32 | 33 | # The Docs are available at the path mentioned below. We can override the index.html to automatically redirect to the documentation page. 34 | - name: Prepare static files 35 | run: | 36 | echo '' > ./docs-site/index.html 37 | 38 | - name: Upload static files as artifact 39 | id: deployment 40 | uses: actions/upload-pages-artifact@v3 41 | with: 42 | path: docs-site 43 | outputs: 44 | page_url: ${{ steps.deployment.outputs.page_url }} 45 | 46 | # Deployment job 47 | deploy: 48 | if: ${{ github.ref == 'refs/heads/main' }} 49 | environment: 50 | name: github-pages 51 | url: ${{ needs.build.outputs.page_url }} 52 | runs-on: ubuntu-latest 53 | needs: build 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release PowerSync 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Version number (e.g., 1.0.0 or 1.0.0-Beta.1)" 8 | required: true 9 | type: string 10 | release_notes: 11 | description: "Release notes" 12 | required: true 13 | type: string 14 | 15 | jobs: 16 | build: 17 | uses: ./.github/workflows/build_and_test.yaml 18 | release: 19 | needs: build 20 | runs-on: macos-latest 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Validate version format and set prerelease flag 29 | id: version_check 30 | run: | 31 | if [[ ${{ github.event.inputs.version }} =~ ^[0-9]+\.[0-9]+\.[0-9]+(-Beta\.[0-9]+)?$ ]]; then 32 | if [[ ${{ github.event.inputs.version }} =~ -Beta\.[0-9]+$ ]]; then 33 | echo "is_prerelease=true" >> $GITHUB_OUTPUT 34 | echo "Version is valid Beta format" 35 | else 36 | echo "is_prerelease=false" >> $GITHUB_OUTPUT 37 | echo "Version is valid release format" 38 | fi 39 | else 40 | echo "Invalid version format. Must be either:" 41 | echo "- Release version: X.Y.Z (e.g., 1.0.0)" 42 | echo "- Beta version: X.Y.Z-Beta.N (e.g., 1.0.0-Beta.1)" 43 | exit 1 44 | fi 45 | 46 | - name: Create Git tag 47 | run: | 48 | git tag ${{ github.event.inputs.version }} 49 | git push origin ${{ github.event.inputs.version }} 50 | 51 | - name: Create GitHub Release 52 | uses: ncipollo/release-action@v1 53 | with: 54 | tag: ${{ github.event.inputs.version }} 55 | name: PowerSync ${{ github.event.inputs.version }} 56 | body: ${{ github.event.inputs.release_notes }} 57 | draft: false 58 | prerelease: ${{ steps.version_check.outputs.is_prerelease }} 59 | token: ${{ secrets.GITHUB_TOKEN }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | 64 | .DS_Store 65 | /.build 66 | /Packages 67 | xcuserdata/ 68 | DerivedData/ 69 | .swiftpm/configuration/registries.json 70 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 71 | .netrc 72 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "2d885a1b46f17f9239b7876e3889168a6de98024718f2d7af03aede290c8a86a", 3 | "pins" : [ 4 | { 5 | "identity" : "anycodable", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Flight-School/AnyCodable", 8 | "state" : { 9 | "revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05", 10 | "version" : "0.6.7" 11 | } 12 | }, 13 | { 14 | "identity" : "powersync-kotlin", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/powersync-ja/powersync-kotlin.git", 17 | "state" : { 18 | "revision" : "ccd2e595195c59d570eb93a878ad6a5cfca72ada", 19 | "version" : "1.0.1+SWIFT.0" 20 | } 21 | }, 22 | { 23 | "identity" : "powersync-sqlite-core-swift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", 26 | "state" : { 27 | "revision" : "3a7fcb3be83db5b450effa5916726b19828cbcb7", 28 | "version" : "0.3.14" 29 | } 30 | }, 31 | { 32 | "identity" : "supabase-swift", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/supabase-community/supabase-swift.git", 35 | "state" : { 36 | "revision" : "8f5b94f6a7a35305ccc1726f2f8f9d415ee2ec50", 37 | "version" : "2.20.4" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-asn1", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-asn1.git", 44 | "state" : { 45 | "revision" : "7faebca1ea4f9aaf0cda1cef7c43aecd2311ddf6", 46 | "version" : "1.3.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-case-paths", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/pointfreeco/swift-case-paths", 53 | "state" : { 54 | "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", 55 | "version" : "1.5.6" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-collections", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-collections", 62 | "state" : { 63 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 64 | "version" : "1.1.4" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-concurrency-extras", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 71 | "state" : { 72 | "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", 73 | "version" : "1.2.0" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-crypto", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/apple/swift-crypto.git", 80 | "state" : { 81 | "revision" : "21f7878f2b39d46fd8ba2b06459ccb431cdf876c", 82 | "version" : "3.8.1" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-custom-dump", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 89 | "state" : { 90 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 91 | "version" : "1.3.3" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-http-types", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-http-types.git", 98 | "state" : { 99 | "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", 100 | "version" : "1.3.0" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-identified-collections", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 107 | "state" : { 108 | "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", 109 | "version" : "1.1.0" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-syntax", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/swiftlang/swift-syntax", 116 | "state" : { 117 | "revision" : "0687f71944021d616d34d922343dcef086855920", 118 | "version" : "600.0.1" 119 | } 120 | }, 121 | { 122 | "identity" : "swiftui-navigation", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/pointfreeco/swiftui-navigation", 125 | "state" : { 126 | "revision" : "e628806aeaa9efe25c1abcd97931a7c498fab281", 127 | "version" : "1.5.5" 128 | } 129 | }, 130 | { 131 | "identity" : "xctest-dynamic-overlay", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 134 | "state" : { 135 | "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", 136 | "version" : "1.4.2" 137 | } 138 | } 139 | ], 140 | "version" : 3 141 | } 142 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample.xcodeproj/xcshareddata/xcschemes/PowerSyncExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 59 | 60 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/.ci/pre_build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cp _Secrets.swift Secrets.swift 4 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Components/AddListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AddListView: View { 4 | @Environment(SystemManager.self) private var system 5 | 6 | @Binding var newList: NewListContent 7 | let completion: (Result) -> Void 8 | 9 | var body: some View { 10 | Section { 11 | TextField("Name", text: $newList.name) 12 | Button("Save") { 13 | Task { 14 | do { 15 | try await system.insertList(newList) 16 | await completion(.success(true)) 17 | } catch { 18 | await completion(.failure(error)) 19 | throw error 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | #Preview { 28 | AddListView( 29 | newList: .constant( 30 | .init( 31 | name: "", 32 | ownerId: "", 33 | createdAt: "" 34 | ) 35 | ) 36 | ) { _ in 37 | }.environment(SystemManager()) 38 | } 39 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Components/AddTodoListView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct AddTodoListView: View { 5 | @Environment(SystemManager.self) private var system 6 | 7 | @Binding var newTodo: NewTodo 8 | let listId: String 9 | let completion: (Result) -> Void 10 | 11 | var body: some View { 12 | Section { 13 | TextField("Description", text: $newTodo.description) 14 | Button("Save") { 15 | Task{ 16 | do { 17 | try await system.insertTodo(newTodo, listId) 18 | await completion(.success(true)) 19 | } catch { 20 | await completion(.failure(error)) 21 | throw error 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | 29 | #Preview { 30 | AddTodoListView( 31 | newTodo: .constant( 32 | .init( 33 | listId: UUID().uuidString.lowercased(), 34 | isComplete: false, 35 | description: "" 36 | ) 37 | ), 38 | listId: UUID().uuidString.lowercased() 39 | ){ _ in 40 | }.environment(SystemManager()) 41 | } 42 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Components/ListRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | 4 | struct ListRow: View { 5 | let list: ListContent 6 | 7 | var body: some View { 8 | HStack { 9 | Text(list.name) 10 | Spacer() 11 | .buttonStyle(.plain) 12 | } 13 | } 14 | } 15 | 16 | 17 | #Preview { 18 | ListRow( 19 | list: .init( 20 | id: UUID().uuidString.lowercased(), 21 | name: "name", 22 | createdAt: "", 23 | ownerId: UUID().uuidString.lowercased() 24 | ) 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Components/ListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import IdentifiedCollections 3 | import SwiftUINavigation 4 | import PowerSync 5 | 6 | struct ListView: View { 7 | @Environment(SystemManager.self) private var system 8 | 9 | @State private var lists: IdentifiedArrayOf = [] 10 | @State private var error: Error? 11 | @State private var newList: NewListContent? 12 | @State private var editing: Bool = false 13 | @State private var status: SyncStatusData? = nil 14 | 15 | var body: some View { 16 | if status?.hasSynced != true { 17 | VStack { 18 | if let status = self.status { 19 | if status.hasSynced != true { 20 | Text("Busy with initial sync...") 21 | 22 | if let progress = status.downloadProgress { 23 | ProgressView(value: progress.fraction) 24 | 25 | if progress.downloadedOperations == progress.totalOperations { 26 | Text("Applying server-side changes...") 27 | } else { 28 | Text("Downloaded \(progress.downloadedOperations) out of \(progress.totalOperations)") 29 | } 30 | } 31 | } 32 | } else { 33 | ProgressView() 34 | .progressViewStyle(CircularProgressViewStyle()) 35 | } 36 | } 37 | } 38 | 39 | List { 40 | if let error { 41 | ErrorText(error) 42 | } 43 | 44 | IfLet($newList) { $newList in 45 | AddListView(newList: $newList) { result in 46 | withAnimation { 47 | self.newList = nil 48 | } 49 | } 50 | } 51 | 52 | ForEach(lists) { list in 53 | NavigationLink(destination: TodosScreen( 54 | listId: list.id 55 | )) { 56 | ListRow(list: list) 57 | } 58 | } 59 | .onDelete { indexSet in 60 | Task { 61 | await handleDelete(at: indexSet) 62 | } 63 | } 64 | } 65 | .animation(.default, value: lists) 66 | .navigationTitle("Lists") 67 | .toolbar { 68 | ToolbarItem(placement: .primaryAction) { 69 | if (newList == nil) { 70 | Button { 71 | withAnimation { 72 | newList = .init( 73 | name: "", 74 | ownerId: "", 75 | createdAt: "" 76 | ) 77 | } 78 | } label: { 79 | Label("Add", systemImage: "plus") 80 | } 81 | } else { 82 | Button("Cancel", role: .cancel) { 83 | withAnimation { 84 | newList = nil 85 | } 86 | } 87 | } 88 | } 89 | } 90 | .task { 91 | await system.watchLists { ls in 92 | withAnimation { 93 | self.lists = IdentifiedArrayOf(uniqueElements: ls) 94 | } 95 | } 96 | } 97 | .task { 98 | self.status = system.db.currentStatus 99 | 100 | for await status in system.db.currentStatus.asFlow() { 101 | self.status = status 102 | } 103 | } 104 | } 105 | 106 | func handleDelete(at offset: IndexSet) async { 107 | do { 108 | error = nil 109 | let listsToDelete = offset.map { lists[$0] } 110 | 111 | try await system.deleteList(id: listsToDelete[0].id) 112 | 113 | } catch { 114 | self.error = error 115 | } 116 | } 117 | } 118 | 119 | #Preview { 120 | NavigationStack { 121 | ListView() 122 | .environment(SystemManager()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Components/TodoListRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TodoListRow: View { 4 | let todo: Todo 5 | let isCameraAvailable: Bool 6 | let completeTapped: () -> Void 7 | let deletePhotoTapped: () -> Void 8 | let capturePhotoTapped: () -> Void 9 | let selectPhotoTapped: () -> Void 10 | 11 | #if os(iOS) 12 | @State private var image: UIImage? = nil 13 | #endif 14 | 15 | var body: some View { 16 | HStack { 17 | Text(todo.description) 18 | #if os(iOS) 19 | Group { 20 | if let image = image { 21 | Image(uiImage: image) 22 | .resizable() 23 | .scaledToFit() 24 | 25 | } else if todo.photoUri != nil { 26 | // Show progress while loading the image 27 | ProgressView() 28 | .onAppear { 29 | Task { 30 | await loadImage() 31 | } 32 | } 33 | } else if todo.photoId != nil { 34 | // Show progres, wait for a URI to be present 35 | ProgressView() 36 | } else { 37 | EmptyView() 38 | } 39 | } 40 | #endif 41 | Spacer() 42 | VStack { 43 | if todo.photoId == nil { 44 | HStack { 45 | if isCameraAvailable { 46 | Button { 47 | capturePhotoTapped() 48 | } label: { 49 | Image(systemName: "camera.fill") 50 | } 51 | .buttonStyle(.plain) 52 | } 53 | Button { 54 | selectPhotoTapped() 55 | } label: { 56 | Image(systemName: "photo.on.rectangle") 57 | } 58 | .buttonStyle(.plain) 59 | } 60 | } else { 61 | Button { 62 | deletePhotoTapped() 63 | } label: { 64 | Image(systemName: "trash.fill") 65 | } 66 | .buttonStyle(.plain) 67 | } 68 | Spacer() 69 | Button { 70 | completeTapped() 71 | } label: { 72 | Image(systemName: todo.isComplete ? "checkmark.circle.fill" : "circle") 73 | } 74 | .buttonStyle(.plain) 75 | }.onChange(of: todo.photoId) { _, newPhotoId in 76 | #if os(iOS) 77 | if newPhotoId == nil { 78 | // Clear the image when photoId becomes nil 79 | image = nil 80 | } 81 | #endif 82 | } 83 | } 84 | } 85 | 86 | #if os(iOS) 87 | private func loadImage() async { 88 | guard let urlString = todo.photoUri else { return } 89 | let url = URL(fileURLWithPath: urlString) 90 | 91 | do { 92 | let data = try Data(contentsOf: url) 93 | if let loadedImage = UIImage(data: data) { 94 | image = loadedImage 95 | } else { 96 | print("Failed to decode image from data.") 97 | } 98 | } catch { 99 | print("Error loading image from disk:", error) 100 | } 101 | } 102 | #endif 103 | } 104 | 105 | #Preview { 106 | TodoListRow( 107 | todo: .init( 108 | id: UUID().uuidString.lowercased(), 109 | listId: UUID().uuidString.lowercased(), 110 | photoId: nil, 111 | description: "description", 112 | isComplete: false, 113 | createdAt: "", 114 | completedAt: nil, 115 | createdBy: UUID().uuidString.lowercased(), 116 | completedBy: nil, 117 | 118 | ), 119 | isCameraAvailable: true, 120 | completeTapped: {}, 121 | deletePhotoTapped: {}, 122 | capturePhotoTapped: {} 123 | ) {} 124 | } 125 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Components/WifiIcon.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | 5 | struct WifiIcon: View { 6 | let isConnected: Bool 7 | 8 | var body: some View { 9 | let iconName = isConnected ? "wifi" : "wifi.slash" 10 | let description = isConnected ? "Online" : "Offline" 11 | 12 | Image(systemName: iconName) 13 | .accessibility(label: Text(description)) 14 | } 15 | } 16 | 17 | #Preview { 18 | VStack { 19 | WifiIcon(isConnected: true) 20 | WifiIcon(isConnected: false) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Constants { 4 | static let redirectToURL = URL(string: "com.powersync.PowerSyncExample://")! 5 | } 6 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Debug.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func debug( 4 | _ message: @autoclosure () -> String, 5 | function: String = #function, 6 | file: String = #file, 7 | line: UInt = #line 8 | ) { 9 | assert( 10 | { 11 | let fileHandle = FileHandle.standardError 12 | 13 | let logLine = "[\(function) \(file.split(separator: "/").last!):\(line)] \(message())\n" 14 | fileHandle.write(Data(logLine.utf8)) 15 | 16 | return true 17 | }() 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/ErrorText.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ErrorText: View { 4 | let error: Error 5 | 6 | init(_ error: Error) { 7 | self.error = error 8 | } 9 | 10 | var body: some View { 11 | Text(error.localizedDescription) 12 | .foregroundColor(.red) 13 | .font(.footnote) 14 | } 15 | } 16 | 17 | struct ErrorText_Previews: PreviewProvider { 18 | static var previews: some View { 19 | ErrorText(NSError()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Navigation.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum Route: Hashable { 4 | case home 5 | case signIn 6 | case signUp 7 | } 8 | 9 | @Observable 10 | class AuthModel { 11 | var isAuthenticated = false 12 | } 13 | 14 | @Observable 15 | class NavigationModel { 16 | var path = NavigationPath() 17 | } 18 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/PowerSync/Lists.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSync 3 | 4 | struct ListContent: Identifiable, Hashable, Decodable { 5 | let id: String 6 | var name: String 7 | var createdAt: String 8 | var ownerId: String 9 | 10 | enum CodingKeys: String, CodingKey { 11 | case id 12 | case name 13 | case createdAt = "created_at" 14 | case ownerId = "owner_id" 15 | } 16 | } 17 | 18 | struct NewListContent: Encodable { 19 | var name: String 20 | var ownerId: String 21 | var createdAt: String 22 | } 23 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/PowerSync/Schema.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSync 3 | 4 | let LISTS_TABLE = "lists" 5 | let TODOS_TABLE = "todos" 6 | 7 | let lists = Table( 8 | name: LISTS_TABLE, 9 | columns: [ 10 | // ID column is automatically included 11 | .text("name"), 12 | .text("created_at"), 13 | .text("owner_id") 14 | ] 15 | ) 16 | 17 | let todos = Table( 18 | name: TODOS_TABLE, 19 | // ID column is automatically included 20 | columns: [ 21 | Column.text("list_id"), 22 | Column.text("photo_id"), 23 | Column.text("description"), 24 | // 0 or 1 to represent false or true 25 | Column.integer("completed"), 26 | Column.text("created_at"), 27 | Column.text("completed_at"), 28 | Column.text("created_by"), 29 | Column.text("completed_by") 30 | ], 31 | indexes: [ 32 | Index( 33 | name: "list_id", 34 | columns: [IndexedColumn.ascending("list_id")] 35 | ) 36 | ] 37 | ) 38 | 39 | let AppSchema = Schema( 40 | lists, 41 | todos, 42 | createAttachmentTable(name: "attachments") 43 | ) 44 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift: -------------------------------------------------------------------------------- 1 | import AnyCodable 2 | import Auth 3 | import PowerSync 4 | import Supabase 5 | import SwiftUI 6 | 7 | private enum PostgresFatalCodes { 8 | /// Postgres Response codes that we cannot recover from by retrying. 9 | static let fatalResponseCodes: [String] = [ 10 | // Class 22 — Data Exception 11 | // Examples include data type mismatch. 12 | "22...", 13 | // Class 23 — Integrity Constraint Violation. 14 | // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. 15 | "23...", 16 | // INSUFFICIENT PRIVILEGE - typically a row-level security violation 17 | "42501", 18 | ] 19 | 20 | static func isFatalError(_ code: String) -> Bool { 21 | return fatalResponseCodes.contains { pattern in 22 | code.range(of: pattern, options: [.regularExpression]) != nil 23 | } 24 | } 25 | 26 | static func extractErrorCode(from error: any Error) -> String? { 27 | // Look for code: Optional("XXXXX") pattern 28 | let errorString = String(describing: error) 29 | if let range = errorString.range(of: "code: Optional\\(\"([^\"]+)\"\\)", options: .regularExpression), 30 | let codeRange = errorString[range].range(of: "\"([^\"]+)\"", options: .regularExpression) 31 | { 32 | // Extract just the code from within the quotes 33 | let code = errorString[codeRange].dropFirst().dropLast() 34 | return String(code) 35 | } 36 | return nil 37 | } 38 | } 39 | 40 | @Observable 41 | class SupabaseConnector: PowerSyncBackendConnector { 42 | let powerSyncEndpoint: String = Secrets.powerSyncEndpoint 43 | let client: SupabaseClient = .init(supabaseURL: Secrets.supabaseURL, supabaseKey: Secrets.supabaseAnonKey) 44 | var session: Session? 45 | private var errorCode: String? 46 | 47 | @ObservationIgnored 48 | private var observeAuthStateChangesTask: Task? 49 | 50 | override init() { 51 | super.init() 52 | observeAuthStateChangesTask = Task { [weak self] in 53 | guard let self = self else { return } 54 | 55 | for await (event, session) in self.client.auth.authStateChanges { 56 | guard [.initialSession, .signedIn, .signedOut].contains(event) else { throw AuthError.sessionMissing } 57 | 58 | self.session = session 59 | } 60 | } 61 | } 62 | 63 | var currentUserID: String { 64 | guard let id = session?.user.id else { 65 | preconditionFailure("Required session.") 66 | } 67 | 68 | return id.uuidString.lowercased() 69 | } 70 | 71 | func getStorageBucket() -> StorageFileApi? { 72 | guard let bucket = Secrets.supabaseStorageBucket else { 73 | return nil 74 | } 75 | 76 | return client.storage.from(bucket) 77 | } 78 | 79 | override func fetchCredentials() async throws -> PowerSyncCredentials? { 80 | session = try await client.auth.session 81 | 82 | if session == nil { 83 | throw AuthError.sessionMissing 84 | } 85 | 86 | let token = session!.accessToken 87 | 88 | return PowerSyncCredentials(endpoint: powerSyncEndpoint, token: token) 89 | } 90 | 91 | override func uploadData(database: PowerSyncDatabaseProtocol) async throws { 92 | guard let transaction = try await database.getNextCrudTransaction() else { return } 93 | 94 | var lastEntry: CrudEntry? 95 | do { 96 | for entry in transaction.crud { 97 | lastEntry = entry 98 | let tableName = entry.table 99 | 100 | let table = client.from(tableName) 101 | 102 | switch entry.op { 103 | case .put: 104 | var data = entry.opData ?? [:] 105 | data["id"] = entry.id 106 | try await table.upsert(data).execute() 107 | case .patch: 108 | guard let opData = entry.opData else { continue } 109 | try await table.update(opData).eq("id", value: entry.id).execute() 110 | case .delete: 111 | try await table.delete().eq("id", value: entry.id).execute() 112 | } 113 | } 114 | 115 | try await transaction.complete() 116 | 117 | } catch { 118 | if let errorCode = PostgresFatalCodes.extractErrorCode(from: error), 119 | PostgresFatalCodes.isFatalError(errorCode) 120 | { 121 | /// Instead of blocking the queue with these errors, 122 | /// discard the (rest of the) transaction. 123 | /// 124 | /// Note that these errors typically indicate a bug in the application. 125 | /// If protecting against data loss is important, save the failing records 126 | /// elsewhere instead of discarding, and/or notify the user. 127 | print("Data upload error: \(error)") 128 | print("Discarding entry: \(lastEntry!)") 129 | try await transaction.complete() 130 | return 131 | } 132 | 133 | print("Data upload error - retrying last entry: \(lastEntry!), \(error)") 134 | throw error 135 | } 136 | } 137 | 138 | deinit { 139 | observeAuthStateChangesTask?.cancel() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/PowerSync/SupabaseRemoteStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSync 3 | import Supabase 4 | 5 | class SupabaseRemoteStorage: RemoteStorageAdapter { 6 | let storage: Supabase.StorageFileApi 7 | 8 | init(storage: Supabase.StorageFileApi) { 9 | self.storage = storage 10 | } 11 | 12 | func uploadFile(fileData: Data, attachment: PowerSync.Attachment) async throws { 13 | try await storage.upload(attachment.filename, data: fileData) 14 | } 15 | 16 | func downloadFile(attachment: PowerSync.Attachment) async throws -> Data { 17 | try await storage.download(path: attachment.filename) 18 | } 19 | 20 | func deleteFile(attachment: PowerSync.Attachment) async throws { 21 | _ = try await storage.remove(paths: [attachment.filename]) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/PowerSync/Todos.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSync 3 | 4 | struct Todo: Identifiable, Hashable, Decodable { 5 | let id: String 6 | var listId: String 7 | var photoId: String? 8 | var description: String 9 | var isComplete: Bool = false 10 | var createdAt: String? 11 | var completedAt: String? 12 | var createdBy: String? 13 | var completedBy: String? 14 | var photoUri: String? 15 | 16 | enum CodingKeys: String, CodingKey { 17 | case id 18 | case listId = "list_id" 19 | case isComplete = "completed" 20 | case description 21 | case createdAt = "created_at" 22 | case completedAt = "completed_at" 23 | case createdBy = "created_by" 24 | case completedBy = "completed_by" 25 | case photoId = "photo_id" 26 | case photoUri = "photo_uri" 27 | } 28 | } 29 | 30 | struct NewTodo: Encodable { 31 | var listId: String 32 | var isComplete: Bool = false 33 | var description: String 34 | var createdAt: String? 35 | var completedAt: String? 36 | var createdBy: String? 37 | var completedBy: String? 38 | var photoId: String? 39 | } 40 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/PowerSyncExampleApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import PowerSync 3 | 4 | @main 5 | struct PowerSyncExampleApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | RootView() 9 | .environment(SystemManager()) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/RootView.swift: -------------------------------------------------------------------------------- 1 | import Auth 2 | import SwiftUI 3 | 4 | struct RootView: View { 5 | @Environment(SystemManager.self) var system 6 | 7 | @State private var authModel = AuthModel() 8 | @State private var navigationModel = NavigationModel() 9 | 10 | var body: some View { 11 | NavigationStack(path: $navigationModel.path) { 12 | Group { 13 | if authModel.isAuthenticated { 14 | HomeScreen() 15 | } else { 16 | SignInScreen() 17 | } 18 | } 19 | .navigationDestination(for: Route.self) { route in 20 | switch route { 21 | case .home: 22 | HomeScreen() 23 | case .signIn: 24 | SignInScreen() 25 | case .signUp: 26 | SignUpScreen() 27 | } 28 | } 29 | } 30 | .environment(authModel) 31 | .environment(navigationModel) 32 | } 33 | } 34 | 35 | #Preview { 36 | RootView() 37 | .environment(SystemManager()) 38 | } 39 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Screens/HomeScreen.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Auth 3 | import SwiftUI 4 | 5 | struct HomeScreen: View { 6 | @Environment(SystemManager.self) private var system 7 | @Environment(AuthModel.self) private var authModel 8 | @Environment(NavigationModel.self) private var navigationModel 9 | 10 | 11 | var body: some View { 12 | 13 | ListView() 14 | .toolbar { 15 | ToolbarItem(placement: .cancellationAction) { 16 | Button("Sign out") { 17 | Task { 18 | try await system.signOut() 19 | authModel.isAuthenticated = false 20 | navigationModel.path = NavigationPath() 21 | } 22 | } 23 | } 24 | } 25 | .task { 26 | if(system.db.currentStatus.connected == false) { 27 | await system.connect() 28 | } 29 | } 30 | .navigationBarBackButtonHidden(true) 31 | } 32 | } 33 | 34 | #Preview { 35 | NavigationStack{ 36 | HomeScreen() 37 | .environment(SystemManager()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Screens/SignInScreen.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private enum ActionState { 4 | case idle 5 | case inFlight 6 | case result(Result) 7 | } 8 | 9 | struct SignInScreen: View { 10 | @Environment(SystemManager.self) private var system 11 | @Environment(AuthModel.self) private var authModel 12 | @Environment(NavigationModel.self) private var navigationModel 13 | 14 | @State private var email = "" 15 | @State private var password = "" 16 | @State private var actionState = ActionState.idle 17 | 18 | var body: some View { 19 | Form { 20 | Section { 21 | TextField("Email", text: $email) 22 | .textContentType(.emailAddress) 23 | .autocorrectionDisabled() 24 | #if os(iOS) 25 | .keyboardType(.emailAddress) 26 | .textInputAutocapitalization(.never) 27 | #endif 28 | 29 | SecureField("Password", text: $password) 30 | .textContentType(.password) 31 | .autocorrectionDisabled() 32 | #if os(iOS) 33 | .textInputAutocapitalization(.never) 34 | #endif 35 | } 36 | 37 | Section { 38 | Button("Sign in") { 39 | Task { 40 | await signInButtonTapped() 41 | } 42 | } 43 | } 44 | 45 | switch actionState { 46 | case .idle: 47 | EmptyView() 48 | case .inFlight: 49 | ProgressView() 50 | case let .result(.failure(error)): 51 | ErrorText(error) 52 | case .result(.success): 53 | Text("Sign in successful!") 54 | } 55 | 56 | Section { 57 | Button("Don't have an account? Sign up") { 58 | navigationModel.path.append(Route.signUp) 59 | } 60 | } 61 | } 62 | } 63 | 64 | private func signInButtonTapped() async { 65 | do { 66 | actionState = .inFlight 67 | try await system.connector.client.auth.signIn(email: email, password: password) 68 | actionState = .result(.success(())) 69 | authModel.isAuthenticated = true 70 | navigationModel.path = NavigationPath() 71 | } catch { 72 | withAnimation { 73 | actionState = .result(.failure(error)) 74 | } 75 | } 76 | } 77 | } 78 | 79 | #Preview { 80 | NavigationStack { 81 | SignInScreen() 82 | .environment(SystemManager()) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Screens/SignUpScreen.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private enum ActionState { 4 | case idle 5 | case inFlight 6 | case result(Result) 7 | } 8 | 9 | struct SignUpScreen: View { 10 | @Environment(SystemManager.self) private var system 11 | @Environment(AuthModel.self) private var authModel 12 | @Environment(NavigationModel.self) private var navigationModel 13 | 14 | @State private var email = "" 15 | @State private var password = "" 16 | @State private var actionState = ActionState.idle 17 | @State private var navigateToHome = false 18 | 19 | var body: some View { 20 | Form { 21 | Section { 22 | TextField("Email", text: $email) 23 | .textContentType(.emailAddress) 24 | .autocorrectionDisabled() 25 | #if os(ios) 26 | .autocorrectionDisabled() 27 | .textInputAutocapitalization(.never) 28 | #endif 29 | 30 | SecureField("Password", text: $password) 31 | .textContentType(.password) 32 | .autocorrectionDisabled() 33 | #if os(ios) 34 | .textInputAutocapitalization(.never) 35 | #endif 36 | } 37 | 38 | Section { 39 | Button("Sign up") { 40 | Task { 41 | await signUpButtonTapped() 42 | } 43 | } 44 | } 45 | 46 | switch actionState { 47 | case .idle: 48 | EmptyView() 49 | case .inFlight: 50 | ProgressView() 51 | case let .result(.failure(error)): 52 | ErrorText(error) 53 | case .result(.success): 54 | Text("Sign up successful!") 55 | } 56 | } 57 | } 58 | 59 | 60 | private func signUpButtonTapped() async { 61 | do { 62 | actionState = .inFlight 63 | try await system.connector.client.auth.signUp( 64 | email: email, 65 | password: password, 66 | redirectTo: Constants.redirectToURL 67 | ) 68 | actionState = .result(.success(())) 69 | authModel.isAuthenticated = true 70 | navigationModel.path = NavigationPath() 71 | } catch { 72 | withAnimation { 73 | actionState = .result(.failure(error)) 74 | } 75 | } 76 | } 77 | } 78 | 79 | #Preview { 80 | NavigationStack { 81 | SignUpScreen() 82 | .environment(SystemManager()) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/Screens/TodosScreen.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct TodosScreen: View { 5 | let listId: String 6 | 7 | var body: some View { 8 | TodoListView( 9 | listId: listId 10 | ) 11 | } 12 | } 13 | 14 | #Preview { 15 | NavigationStack { 16 | TodosScreen( 17 | listId: UUID().uuidString.lowercased() 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Demo/PowerSyncExample/_Secrets.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // Enter your Supabase and PowerSync project details. 4 | enum Secrets { 5 | static let powerSyncEndpoint = "https://your-id.powersync.journeyapps.com" 6 | static let supabaseURL = URL(string: "https://your-id.supabase.co")! 7 | static let supabaseAnonKey = "anon-key" 8 | // Optional storage bucket name. Set to nil if you don't want to use storage. 9 | static let supabaseStorageBucket: String? = nil 10 | } -------------------------------------------------------------------------------- /Demo/README.md: -------------------------------------------------------------------------------- 1 | # PowerSync Swift Demo App 2 | 3 | A Todo List app demonstrating the use of the PowerSync Swift SDK together with Supabase. 4 | 5 | ## Set up your Supabase and PowerSync projects 6 | 7 | To run this demo, you need Supabase and PowerSync projects. Detailed instructions for integrating PowerSync with Supabase can be found in [the integration guide](https://docs.powersync.com/integration-guides/supabase). 8 | 9 | Follow this guide to: 10 | 11 | 1. Create and configure a Supabase project. 12 | 2. Create a new PowerSync instance, connecting to the database of the Supabase project. See instructions [here](https://docs.powersync.com/integration-guides/supabase-+-powersync#connect-powersync-to-your-supabase). 13 | 3. Deploy sync rules. 14 | 15 | ## Configure The App 16 | 17 | 1. Open this directory in XCode. 18 | 19 | 2. Open the “_Secrets” file and insert the credentials of your Supabase and PowerSync projects (more info can be found [here](https://docs.powersync.com/integration-guides/supabase-+-powersync#test-everything-using-our-demo-app)). 20 | 21 | 3. You will need to enable `CasePathMacros` for SwiftUI Navigation. You can do this in settings, or just build the app and a dialog will be shown to enable `CasePathMacros`. 22 | 23 | ### Troubleshooting 24 | 25 | If you run into build issues, try: 26 | 27 | 1. Clear Swift caches 28 | 29 | ```bash 30 | rm -rf ~/Library/Caches/org.swift.swiftpm 31 | rm -rf ~/Library/org.swift.swiftpm 32 | ``` 33 | 34 | 2. In Xcode: 35 | 36 | - Reset Packages: File -> Packages -> Reset Package Caches 37 | - Clean Build: Product -> Clean Build Folder. 38 | 39 | 40 | ## Run project 41 | 42 | Build the project, launch the app and sign in or register a new user. 43 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "powersync-sqlite-core-swift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", 7 | "state" : { 8 | "revision" : "3a7fcb3be83db5b450effa5916726b19828cbcb7", 9 | "version" : "0.3.14" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | let packageName = "PowerSync" 6 | 7 | // Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin 8 | // build. Also see docs/LocalBuild.md for details 9 | let localKotlinSdkOverride: String? = nil 10 | 11 | // Our target and dependency setup is different when a local Kotlin SDK is used. Without the local 12 | // SDK, we have no package dependency on Kotlin and download the XCFramework from Kotlin releases as 13 | // a binary target. 14 | // With a local SDK, we point to a `Package.swift` within the Kotlin SDK containing a target pointing 15 | // towards a local framework build 16 | var conditionalDependencies: [Package.Dependency] = [] 17 | var conditionalTargets: [Target] = [] 18 | var kotlinTargetDependency = Target.Dependency.target(name: "PowerSyncKotlin") 19 | 20 | if let kotlinSdkPath = localKotlinSdkOverride { 21 | // We can't depend on local XCFrameworks outside of this project's root, so there's a Package.swift 22 | // in the PowerSyncKotlin project pointing towards a local build. 23 | conditionalDependencies.append(.package(path: "\(kotlinSdkPath)/PowerSyncKotlin")) 24 | 25 | kotlinTargetDependency = .product(name: "PowerSyncKotlin", package: "PowerSyncKotlin") 26 | } else { 27 | // Not using a local build, so download from releases 28 | conditionalTargets.append(.binaryTarget( 29 | name: "PowerSyncKotlin", 30 | url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.1.1/PowersyncKotlinRelease.zip", 31 | checksum: "780437e25d41e46c2c1f555adcf330436f185d3663ef442da7141381d9c0495b" 32 | )) 33 | } 34 | 35 | let package = Package( 36 | name: packageName, 37 | platforms: [ 38 | .iOS(.v13), 39 | .macOS(.v10_15) 40 | ], 41 | products: [ 42 | // Products define the executables and libraries a package produces, making them visible to other packages. 43 | .library( 44 | name: packageName, 45 | targets: ["PowerSync"]), 46 | ], 47 | dependencies: [ 48 | .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.14"..<"0.4.0") 49 | ] + conditionalDependencies, 50 | targets: [ 51 | // Targets are the basic building blocks of a package, defining a module or a test suite. 52 | // Targets can depend on other targets in this package and products from dependencies. 53 | .target( 54 | name: packageName, 55 | dependencies: [ 56 | kotlinTargetDependency, 57 | .product(name: "PowerSyncSQLiteCore", package: "powersync-sqlite-core-swift") 58 | ]), 59 | .testTarget( 60 | name: "PowerSyncTests", 61 | dependencies: ["PowerSync"] 62 | ), 63 | ] + conditionalTargets 64 | ) 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | _[PowerSync](https://www.powersync.com) is a sync engine for building local-first apps with instantly-responsive UI/UX and simplified state transfer. Syncs between SQLite on the client-side and Postgres, MongoDB or MySQL on the server-side._ 6 | 7 | # PowerSync Swift 8 | 9 | This is the PowerSync SDK for Swift clients. The SDK reference is available [here](https://docs.powersync.com/client-sdk-references/swift). 10 | 11 | ## Beta Release 12 | 13 | This SDK is currently in a beta release it is suitable for production use, given you have tested your use case(s) extensively. If you find a bug or issue, please open a [GitHub issue](https://github.com/powersync-ja/powersync-swift/issues). Questions or feedback can be posted on our [community Discord](https://discord.gg/powersync) - we'd love to hear from you. 14 | 15 | ## Structure: Packages 16 | 17 | - [Sources](./Sources/) 18 | 19 | - This is the Swift SDK implementation. 20 | 21 | ## Demo Apps / Example Projects 22 | 23 | The easiest way to test the PowerSync Swift SDK is to run our demo application. 24 | 25 | - [Demo/PowerSyncExample](./Demo/README.md): A simple to-do list application demonstrating the use of the PowerSync Swift SDK using a Supabase connector. 26 | 27 | ## Installation 28 | 29 | Add 30 | 31 | ```swift 32 | dependencies: [ 33 | ... 34 | .package(url: "https://github.com/powersync-ja/powersync-swift", exact: "") 35 | ], 36 | targets: [ 37 | .target( 38 | name: "YourTargetName", 39 | dependencies: [ 40 | ... 41 | .product( 42 | name: "PowerSync", 43 | package: "powersync-swift" 44 | ), 45 | ] 46 | ) 47 | ] 48 | ``` 49 | 50 | to your `Package.swift` file and pin the dependency to a specific version. The version is required because the package is in beta. 51 | 52 | to your `Package.swift` file and pin the dependency to a specific version. This is required because the package is in beta. 53 | 54 | ## Usage 55 | 56 | Create a PowerSync client 57 | 58 | ```swift 59 | import PowerSync 60 | 61 | let powersync = PowerSyncDatabase( 62 | schema: Schema( 63 | tables: [ 64 | Table( 65 | name: "users", 66 | columns: [ 67 | .text("count"), 68 | .integer("is_active"), 69 | .real("weight"), 70 | .text("description") 71 | ] 72 | ) 73 | ] 74 | ), 75 | logger: DefaultLogger(minSeverity: .debug) 76 | ) 77 | ``` 78 | 79 | ## Underlying Kotlin Dependency 80 | 81 | The PowerSync Swift SDK currently makes use of the [PowerSync Kotlin Multiplatform SDK](https://github.com/powersync-ja/powersync-kotlin) with the API tool [SKIE](https://skie.touchlab.co/) and KMMBridge under the hood to help generate and publish a native Swift package. We will move to an entirely Swift native API in v1 and do not expect there to be any breaking changes. For more details, see the [Swift SDK reference](https://docs.powersync.com/client-sdk-references/swift). 82 | 83 | ## Migration from Alpha to Beta 84 | 85 | See these [developer notes](https://docs.powersync.com/client-sdk-references/swift#migrating-from-the-alpha-to-the-beta-sdk) if you are migrating from the alpha to the beta version of the Swift SDK. 86 | 87 | ## Attachments 88 | 89 | See the attachments [README](./Sources/PowerSync/attachments/README.md) for more information. 90 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/DatabaseLogger.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// Adapts a Swift `LoggerProtocol` to Kermit's `LogWriter` interface. 4 | /// 5 | /// This allows Kotlin logging (via Kermit) to call into the Swift logging implementation. 6 | private class KermitLogWriterAdapter: Kermit_coreLogWriter { 7 | /// The underlying Swift log writer to forward log messages to. 8 | let logger: any LoggerProtocol 9 | 10 | /// Initializes a new adapter. 11 | /// 12 | /// - Parameter logger: A Swift log writer that will handle log output. 13 | init(logger: any LoggerProtocol) { 14 | self.logger = logger 15 | super.init() 16 | } 17 | 18 | /// Called by Kermit to log a message. 19 | /// 20 | /// - Parameters: 21 | /// - severity: The severity level of the log. 22 | /// - message: The content of the log message. 23 | /// - tag: A string categorizing the log. 24 | /// - throwable: An optional Kotlin exception (ignored here). 25 | override func log(severity: Kermit_coreSeverity, message: String, tag: String, throwable: KotlinThrowable?) { 26 | switch severity { 27 | case PowerSyncKotlin.Kermit_coreSeverity.verbose: 28 | return logger.debug(message, tag: tag) 29 | case PowerSyncKotlin.Kermit_coreSeverity.debug: 30 | return logger.debug(message, tag: tag) 31 | case PowerSyncKotlin.Kermit_coreSeverity.info: 32 | return logger.info(message, tag: tag) 33 | case PowerSyncKotlin.Kermit_coreSeverity.warn: 34 | return logger.warning(message, tag: tag) 35 | case PowerSyncKotlin.Kermit_coreSeverity.error: 36 | return logger.error(message, tag: tag) 37 | case PowerSyncKotlin.Kermit_coreSeverity.assert: 38 | return logger.fault(message, tag: tag) 39 | } 40 | } 41 | } 42 | 43 | /// A logger implementation that integrates with PowerSync's Kotlin core using Kermit. 44 | /// 45 | /// This class bridges Swift log writers with the Kotlin logging system and supports 46 | /// runtime configuration of severity levels and writer lists. 47 | class DatabaseLogger: LoggerProtocol { 48 | /// The underlying Kermit logger instance provided by the PowerSyncKotlin SDK. 49 | public let kLogger = PowerSyncKotlin.generateLogger(logger: nil) 50 | public let logger: any LoggerProtocol 51 | 52 | /// Initializes a new logger with an optional list of writers. 53 | /// 54 | /// - Parameter logger: A logger which will be called for each internal log operation 55 | init(_ logger: any LoggerProtocol) { 56 | self.logger = logger 57 | // Set to the lowest severity. The provided logger should filter by severity 58 | kLogger.mutableConfig.setMinSeverity(Kermit_coreSeverity.verbose) 59 | kLogger.mutableConfig.setLogWriterList( 60 | [KermitLogWriterAdapter(logger: logger)] 61 | ) 62 | } 63 | 64 | /// Logs a debug-level message. 65 | public func debug(_ message: String, tag: String?) { 66 | logger.debug(message, tag: tag) 67 | } 68 | 69 | /// Logs an info-level message. 70 | public func info(_ message: String, tag: String?) { 71 | logger.info(message, tag: tag) 72 | } 73 | 74 | /// Logs a warning-level message. 75 | public func warning(_ message: String, tag: String?) { 76 | logger.warning(message, tag: tag) 77 | } 78 | 79 | /// Logs an error-level message. 80 | public func error(_ message: String, tag: String?) { 81 | logger.error(message, tag: tag) 82 | } 83 | 84 | /// Logs a fault (assert-level) message, typically used for critical issues. 85 | public func fault(_ message: String, tag: String?) { 86 | logger.fault(message, tag: tag) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/KotlinAdapter.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | enum KotlinAdapter { 4 | struct Index { 5 | static func toKotlin(_ index: IndexProtocol) -> PowerSyncKotlin.Index { 6 | PowerSyncKotlin.Index( 7 | name: index.name, 8 | columns: index.columns.map { IndexedColumn.toKotlin($0) } 9 | ) 10 | } 11 | } 12 | 13 | struct IndexedColumn { 14 | static func toKotlin(_ column: IndexedColumnProtocol) -> PowerSyncKotlin.IndexedColumn { 15 | return PowerSyncKotlin.IndexedColumn( 16 | column: column.column, 17 | ascending: column.ascending, 18 | columnDefinition: nil, 19 | type: nil 20 | ) 21 | } 22 | } 23 | 24 | struct Table { 25 | static func toKotlin(_ table: TableProtocol) -> PowerSyncKotlin.Table { 26 | let trackPreviousKotlin: PowerSyncKotlin.TrackPreviousValuesOptions? = if let track = table.trackPreviousValues { 27 | PowerSyncKotlin.TrackPreviousValuesOptions( 28 | columnFilter: track.columnFilter, 29 | onlyWhenChanged: track.onlyWhenChanged 30 | ) 31 | } else { 32 | nil 33 | } 34 | 35 | return PowerSyncKotlin.Table( 36 | name: table.name, 37 | columns: table.columns.map { Column.toKotlin($0) }, 38 | indexes: table.indexes.map { Index.toKotlin($0) }, 39 | localOnly: table.localOnly, 40 | insertOnly: table.insertOnly, 41 | viewNameOverride: table.viewNameOverride, 42 | trackMetadata: table.trackMetadata, 43 | trackPreviousValues: trackPreviousKotlin, 44 | ignoreEmptyUpdates: table.ignoreEmptyUpdates 45 | ) 46 | } 47 | } 48 | 49 | struct Column { 50 | static func toKotlin(_ column: any ColumnProtocol) -> PowerSyncKotlin.Column { 51 | PowerSyncKotlin.Column( 52 | name: column.name, 53 | type: columnType(from: column.type) 54 | ) 55 | } 56 | 57 | private static func columnType(from swiftType: ColumnData) -> PowerSyncKotlin.ColumnType { 58 | switch swiftType { 59 | case .text: 60 | return PowerSyncKotlin.ColumnType.text 61 | case .integer: 62 | return PowerSyncKotlin.ColumnType.integer 63 | case .real: 64 | return PowerSyncKotlin.ColumnType.real 65 | } 66 | } 67 | } 68 | 69 | struct Schema { 70 | static func toKotlin(_ schema: SchemaProtocol) -> PowerSyncKotlin.Schema { 71 | PowerSyncKotlin.Schema( 72 | tables: schema.tables.map { Table.toKotlin($0) } 73 | ) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/KotlinTypes.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | typealias KotlinPowerSyncBackendConnector = PowerSyncKotlin.PowerSyncBackendConnector 4 | typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials 5 | typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase 6 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift: -------------------------------------------------------------------------------- 1 | import OSLog 2 | 3 | class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector { 4 | let swiftBackendConnector: PowerSyncBackendConnector 5 | let db: any PowerSyncDatabaseProtocol 6 | let logTag = "PowerSyncBackendConnector" 7 | 8 | init( 9 | swiftBackendConnector: PowerSyncBackendConnector, 10 | db: any PowerSyncDatabaseProtocol 11 | ) { 12 | self.swiftBackendConnector = swiftBackendConnector 13 | self.db = db 14 | } 15 | 16 | override func __fetchCredentials() async throws -> KotlinPowerSyncCredentials? { 17 | do { 18 | let result = try await swiftBackendConnector.fetchCredentials() 19 | return result?.kotlinCredentials 20 | } catch { 21 | db.logger.error("Error while fetching credentials", tag: logTag) 22 | /// We can't use throwKotlinPowerSyncError here since the Kotlin connector 23 | /// runs this in a Job - this seems to break the SKIEE error propagation. 24 | /// returning nil here should still cause a retry 25 | return nil 26 | } 27 | } 28 | 29 | override func __uploadData(database: KotlinPowerSyncDatabase) async throws { 30 | do { 31 | // Pass the Swift DB protocal to the connector 32 | return try await swiftBackendConnector.uploadData(database: db) 33 | } catch { 34 | db.logger.error("Error while uploading data: \(error)", tag: logTag) 35 | // Relay the error to the Kotlin SDK 36 | try throwKotlinPowerSyncError( 37 | message: "Connector errored while uploading data: \(error.localizedDescription)", 38 | cause: error.localizedDescription 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/SafeCastError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum SafeCastError: Error, CustomStringConvertible { 4 | case typeMismatch(expected: Any.Type, actual: Any?) 5 | 6 | var description: String { 7 | switch self { 8 | case let .typeMismatch(expected, actual): 9 | let actualType = actual.map { String(describing: type(of: $0)) } ?? "nil" 10 | return "Type mismatch: Expected \(expected), but got \(actualType)." 11 | } 12 | } 13 | } 14 | 15 | func safeCast(_ value: Any?, to type: T.Type) throws -> T { 16 | // Special handling for nil when T is an optional type 17 | if value == nil || value is NSNull { 18 | // Check if T is an optional type that can accept nil 19 | let nilValue: Any? = nil 20 | if let nilAsT = nilValue as? T { 21 | return nilAsT 22 | } 23 | } 24 | 25 | if let castedValue = value as? T { 26 | return castedValue 27 | } else { 28 | throw SafeCastError.typeMismatch(expected: type, actual: value) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/TransactionCallback.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// Internal Wrapper for Kotlin lock context lambdas 4 | class LockCallback: PowerSyncKotlin.ThrowableLockCallback { 5 | let callback: (ConnectionContext) throws -> R 6 | 7 | init(callback: @escaping (ConnectionContext) throws -> R) { 8 | self.callback = callback 9 | } 10 | 11 | // The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks. 12 | // If a Swift callback throws an exception, it results in a `BAD ACCESS` crash. 13 | // 14 | // To prevent this, we catch the exception and return it as a `PowerSyncException`, 15 | // allowing Kotlin to propagate the error correctly. 16 | // 17 | // This approach is a workaround. Ideally, we should introduce an internal mechanism 18 | // in the Kotlin SDK to handle errors from Swift more robustly. 19 | // 20 | // Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our 21 | // ability to handle exceptions cleanly. Instead, we should expose an internal implementation 22 | // from a "core" package in Kotlin that provides better control over exception handling 23 | // and other functionality—without modifying the public `PowerSyncDatabase` API to include 24 | // Swift-specific logic. 25 | func execute(context: PowerSyncKotlin.ConnectionContext) throws -> Any { 26 | do { 27 | return try callback( 28 | KotlinConnectionContext( 29 | ctx: context 30 | ) 31 | ) 32 | } catch { 33 | return PowerSyncKotlin.PowerSyncException( 34 | message: error.localizedDescription, 35 | cause: PowerSyncKotlin.KotlinThrowable( 36 | message: error.localizedDescription 37 | ) 38 | ) 39 | } 40 | } 41 | } 42 | 43 | /// Internal Wrapper for Kotlin transaction context lambdas 44 | class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { 45 | let callback: (Transaction) throws -> R 46 | 47 | init(callback: @escaping (Transaction) throws -> R) { 48 | self.callback = callback 49 | } 50 | 51 | func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any { 52 | do { 53 | return try callback( 54 | KotlinTransactionContext( 55 | ctx: transaction 56 | ) 57 | ) 58 | } catch { 59 | return PowerSyncKotlin.PowerSyncException( 60 | message: error.localizedDescription, 61 | cause: PowerSyncKotlin.KotlinThrowable( 62 | message: error.localizedDescription 63 | ) 64 | ) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSyncKotlin 3 | 4 | /// Extension of the `ConnectionContext` protocol which allows mixin of common logic required for Kotlin adapters 5 | protocol KotlinConnectionContextProtocol: ConnectionContext { 6 | /// Implementations should provide access to a Kotlin context. 7 | /// The protocol extension will use this to provide shared implementation. 8 | var ctx: PowerSyncKotlin.ConnectionContext { get } 9 | } 10 | 11 | /// Implements most of `ConnectionContext` using the `ctx` provided. 12 | extension KotlinConnectionContextProtocol { 13 | func execute(sql: String, parameters: [Any?]?) throws -> Int64 { 14 | try ctx.execute( 15 | sql: sql, 16 | parameters: mapParameters(parameters) 17 | ) 18 | } 19 | 20 | func getOptional( 21 | sql: String, 22 | parameters: [Any?]?, 23 | mapper: @escaping (any SqlCursor) throws -> RowType 24 | ) throws -> RowType? { 25 | return try wrapQueryCursorTyped( 26 | mapper: mapper, 27 | executor: { wrappedMapper in 28 | try self.ctx.getOptional( 29 | sql: sql, 30 | parameters: mapParameters(parameters), 31 | mapper: wrappedMapper 32 | ) 33 | }, 34 | resultType: RowType?.self 35 | ) 36 | } 37 | 38 | func getAll( 39 | sql: String, 40 | parameters: [Any?]?, 41 | mapper: @escaping (any SqlCursor) throws -> RowType 42 | ) throws -> [RowType] { 43 | return try wrapQueryCursorTyped( 44 | mapper: mapper, 45 | executor: { wrappedMapper in 46 | try self.ctx.getAll( 47 | sql: sql, 48 | parameters: mapParameters(parameters), 49 | mapper: wrappedMapper 50 | ) 51 | }, 52 | resultType: [RowType].self 53 | ) 54 | } 55 | 56 | func get( 57 | sql: String, 58 | parameters: [Any?]?, 59 | mapper: @escaping (any SqlCursor) throws -> RowType 60 | ) throws -> RowType { 61 | return try wrapQueryCursorTyped( 62 | mapper: mapper, 63 | executor: { wrappedMapper in 64 | try self.ctx.get( 65 | sql: sql, 66 | parameters: mapParameters(parameters), 67 | mapper: wrappedMapper 68 | ) 69 | }, 70 | resultType: RowType.self 71 | ) 72 | } 73 | } 74 | 75 | class KotlinConnectionContext: KotlinConnectionContextProtocol { 76 | let ctx: PowerSyncKotlin.ConnectionContext 77 | 78 | init(ctx: PowerSyncKotlin.ConnectionContext) { 79 | self.ctx = ctx 80 | } 81 | } 82 | 83 | class KotlinTransactionContext: Transaction, KotlinConnectionContextProtocol { 84 | let ctx: PowerSyncKotlin.ConnectionContext 85 | 86 | init(ctx: PowerSyncKotlin.PowerSyncTransaction) { 87 | self.ctx = ctx 88 | } 89 | } 90 | 91 | // Allows nil values to be passed to the Kotlin [Any] params 92 | func mapParameters(_ parameters: [Any?]?) -> [Any] { 93 | parameters?.map { item in 94 | item ?? NSNull() 95 | } ?? [] 96 | } 97 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// Implements `CrudBatch` using the Kotlin SDK 4 | struct KotlinCrudBatch: CrudBatch { 5 | let batch: PowerSyncKotlin.CrudBatch 6 | let crud: [CrudEntry] 7 | 8 | init( 9 | batch: PowerSyncKotlin.CrudBatch) 10 | throws 11 | { 12 | self.batch = batch 13 | self.crud = try batch.crud.map { try KotlinCrudEntry( 14 | entry: $0 15 | ) } 16 | } 17 | 18 | var hasMore: Bool { 19 | batch.hasMore 20 | } 21 | 22 | func complete( 23 | writeCheckpoint: String? 24 | ) async throws { 25 | _ = try await batch.complete.invoke(p1: writeCheckpoint) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// Implements `CrudEntry` using the KotlinSDK 4 | struct KotlinCrudEntry : CrudEntry { 5 | let entry: PowerSyncKotlin.CrudEntry 6 | let op: UpdateType 7 | 8 | init ( 9 | entry: PowerSyncKotlin.CrudEntry 10 | ) throws { 11 | self.entry = entry 12 | self.op = try UpdateType.fromString(entry.op.name) 13 | } 14 | 15 | var id: String { 16 | entry.id 17 | } 18 | 19 | var clientId: Int64 { 20 | Int64(entry.clientId) 21 | } 22 | 23 | var table: String { 24 | entry.table 25 | } 26 | 27 | var transactionId: Int64? { 28 | entry.transactionId?.int64Value 29 | } 30 | 31 | var metadata: String? { 32 | entry.metadata 33 | } 34 | 35 | var opData: [String : String?]? { 36 | /// Kotlin represents this as Map, but this is 37 | /// converted to [String: Any] by SKIEE 38 | entry.opData?.mapValues { $0 as? String } 39 | } 40 | 41 | var previousValues: [String : String?]? { 42 | entry.previousValues?.mapValues { $0 as? String } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// Implements `CrudTransaction` using the Kotlin SDK 4 | struct KotlinCrudTransaction: CrudTransaction { 5 | let transaction: PowerSyncKotlin.CrudTransaction 6 | let crud: [CrudEntry] 7 | 8 | init(transaction: PowerSyncKotlin.CrudTransaction) throws { 9 | self.transaction = transaction 10 | self.crud = try transaction.crud.map { try KotlinCrudEntry( 11 | entry: $0 12 | ) } 13 | } 14 | 15 | var transactionId: Int64? { 16 | transaction.transactionId?.int64Value 17 | } 18 | 19 | func complete(writeCheckpoint: String?) async throws { 20 | _ = try await transaction.complete.invoke(p1: writeCheckpoint) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// Converts a Swift `JsonValue` to one accepted by the Kotlin SDK 4 | extension JsonValue { 5 | func toKotlinMap() -> PowerSyncKotlin.JsonParam { 6 | switch self { 7 | case .string(let value): 8 | return PowerSyncKotlin.JsonParam.String(value: value) 9 | case .int(let value): 10 | return PowerSyncKotlin.JsonParam.Number(value: value) 11 | case .double(let value): 12 | return PowerSyncKotlin.JsonParam.Number(value: value) 13 | case .bool(let value): 14 | return PowerSyncKotlin.JsonParam.Boolean(value: value) 15 | case .null: 16 | return PowerSyncKotlin.JsonParam.Null() 17 | case .array(let array): 18 | return PowerSyncKotlin.JsonParam.Collection( 19 | value: array.map { $0.toKotlinMap() } 20 | ) 21 | case .object(let dict): 22 | var anyDict: [String: PowerSyncKotlin.JsonParam] = [:] 23 | for (key, value) in dict { 24 | anyDict[key] = value.toKotlinMap() 25 | } 26 | return PowerSyncKotlin.JsonParam.Map(value: anyDict) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// Implements `SqlCursor` using the Kotlin SDK 4 | class KotlinSqlCursor: SqlCursor { 5 | let base: PowerSyncKotlin.SqlCursor 6 | 7 | var columnCount: Int 8 | 9 | var columnNames: [String: Int] 10 | 11 | init(base: PowerSyncKotlin.SqlCursor) { 12 | self.base = base 13 | self.columnCount = Int(base.columnCount) 14 | self.columnNames = base.columnNames.mapValues { input in input.intValue } 15 | } 16 | 17 | func getBoolean(index: Int) throws -> Bool { 18 | guard let result = getBooleanOptional(index: index) else { 19 | throw SqlCursorError.nullValueFound(String(index)) 20 | } 21 | return result 22 | } 23 | 24 | func getBooleanOptional(index: Int) -> Bool? { 25 | base.getBoolean( 26 | index: Int32(index) 27 | )?.boolValue 28 | } 29 | 30 | func getBoolean(name: String) throws -> Bool { 31 | guard let result = try getBooleanOptional(name: name) else { 32 | throw SqlCursorError.nullValueFound(name) 33 | } 34 | return result 35 | } 36 | 37 | func getBooleanOptional(name: String) throws -> Bool? { 38 | return getBooleanOptional(index: try guardColumnName(name)) 39 | } 40 | 41 | func getDouble(index: Int) throws -> Double { 42 | guard let result = getDoubleOptional(index: index) else { 43 | throw SqlCursorError.nullValueFound(String(index)) 44 | } 45 | return result 46 | } 47 | 48 | func getDoubleOptional(index: Int) -> Double? { 49 | base.getDouble(index: Int32(index))?.doubleValue 50 | } 51 | 52 | func getDouble(name: String) throws -> Double { 53 | guard let result = try getDoubleOptional(name: name) else { 54 | throw SqlCursorError.nullValueFound(name) 55 | } 56 | return result 57 | } 58 | 59 | func getDoubleOptional(name: String) throws -> Double? { 60 | return getDoubleOptional(index: try guardColumnName(name)) 61 | } 62 | 63 | func getInt(index: Int) throws -> Int { 64 | guard let result = getIntOptional(index: index) else { 65 | throw SqlCursorError.nullValueFound(String(index)) 66 | } 67 | return result 68 | } 69 | 70 | func getIntOptional(index: Int) -> Int? { 71 | base.getLong(index: Int32(index))?.intValue 72 | } 73 | 74 | func getInt(name: String) throws -> Int { 75 | guard let result = try getIntOptional(name: name) else { 76 | throw SqlCursorError.nullValueFound(name) 77 | } 78 | return result 79 | } 80 | 81 | func getIntOptional(name: String) throws -> Int? { 82 | return getIntOptional(index: try guardColumnName(name)) 83 | } 84 | 85 | func getInt64(index: Int) throws -> Int64 { 86 | guard let result = getInt64Optional(index: index) else { 87 | throw SqlCursorError.nullValueFound(String(index)) 88 | } 89 | return result 90 | } 91 | 92 | func getInt64Optional(index: Int) -> Int64? { 93 | base.getLong(index: Int32(index))?.int64Value 94 | } 95 | 96 | func getInt64(name: String) throws -> Int64 { 97 | guard let result = try getInt64Optional(name: name) else { 98 | throw SqlCursorError.nullValueFound(name) 99 | } 100 | return result 101 | } 102 | 103 | func getInt64Optional(name: String) throws -> Int64? { 104 | return getInt64Optional(index: try guardColumnName(name)) 105 | } 106 | 107 | func getString(index: Int) throws -> String { 108 | guard let result = getStringOptional(index: index) else { 109 | throw SqlCursorError.nullValueFound(String(index)) 110 | } 111 | return result 112 | } 113 | 114 | func getStringOptional(index: Int) -> String? { 115 | base.getString(index: Int32(index)) 116 | } 117 | 118 | func getString(name: String) throws -> String { 119 | guard let result = try getStringOptional(name: name) else { 120 | throw SqlCursorError.nullValueFound(name) 121 | } 122 | return result 123 | } 124 | 125 | func getStringOptional(name: String) throws -> String? { 126 | return getStringOptional(index: try guardColumnName(name)) 127 | } 128 | 129 | @discardableResult 130 | private func guardColumnName(_ name: String) throws -> Int { 131 | guard let index = columnNames[name] else { 132 | throw SqlCursorError.columnNotFound(name) 133 | } 134 | return index 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import PowerSyncKotlin 4 | 5 | class KotlinSyncStatus: KotlinSyncStatusDataProtocol, SyncStatus { 6 | private let baseStatus: PowerSyncKotlin.SyncStatus 7 | 8 | var base: any PowerSyncKotlin.SyncStatusData { 9 | baseStatus 10 | } 11 | 12 | init(baseStatus: PowerSyncKotlin.SyncStatus) { 13 | self.baseStatus = baseStatus 14 | } 15 | 16 | func asFlow() -> AsyncStream { 17 | AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in 18 | // Create an outer task to monitor cancellation 19 | let task = Task { 20 | do { 21 | // Watching for changes in the database 22 | for try await value in baseStatus.asFlow() { 23 | // Check if the outer task is cancelled 24 | try Task.checkCancellation() // This checks if the calling task was cancelled 25 | 26 | continuation.yield( 27 | KotlinSyncStatusData(base: value) 28 | ) 29 | } 30 | 31 | continuation.finish() 32 | } catch { 33 | continuation.finish() 34 | } 35 | } 36 | 37 | // Propagate cancellation from the outer task to the inner task 38 | continuation.onTermination = { @Sendable _ in 39 | task.cancel() // This cancels the inner task when the stream is terminated 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSyncKotlin 3 | 4 | /// A protocol extension which allows sharing common implementation using a base sync status 5 | protocol KotlinSyncStatusDataProtocol: SyncStatusData { 6 | var base: PowerSyncKotlin.SyncStatusData { get } 7 | } 8 | 9 | struct KotlinSyncStatusData: KotlinSyncStatusDataProtocol { 10 | let base: PowerSyncKotlin.SyncStatusData 11 | } 12 | 13 | /// Extension of `KotlinSyncStatusDataProtocol` which uses the shared `base` to implement `SyncStatusData` 14 | extension KotlinSyncStatusDataProtocol { 15 | var connected: Bool { 16 | base.connected 17 | } 18 | 19 | var connecting: Bool { 20 | base.connecting 21 | } 22 | 23 | var downloading: Bool { 24 | base.downloading 25 | } 26 | 27 | var uploading: Bool { 28 | base.uploading 29 | } 30 | 31 | var lastSyncedAt: Date? { 32 | guard let lastSyncedAt = base.lastSyncedAt else { return nil } 33 | return Date( 34 | timeIntervalSince1970: Double( 35 | lastSyncedAt.epochSeconds 36 | ) 37 | ) 38 | } 39 | 40 | var downloadProgress: (any SyncDownloadProgress)? { 41 | guard let kotlinProgress = base.downloadProgress else { return nil } 42 | return KotlinSyncDownloadProgress(progress: kotlinProgress) 43 | } 44 | 45 | var hasSynced: Bool? { 46 | base.hasSynced?.boolValue 47 | } 48 | 49 | var uploadError: Any? { 50 | base.uploadError 51 | } 52 | 53 | var downloadError: Any? { 54 | base.downloadError 55 | } 56 | 57 | var anyError: Any? { 58 | base.anyError 59 | } 60 | 61 | public var priorityStatusEntries: [PriorityStatusEntry] { 62 | base.priorityStatusEntries.map { mapPriorityStatus($0) } 63 | } 64 | 65 | public func statusForPriority(_ priority: BucketPriority) -> PriorityStatusEntry { 66 | mapPriorityStatus( 67 | base.statusForPriority( 68 | priority: Int32(priority.priorityCode) 69 | ) 70 | ) 71 | } 72 | 73 | private func mapPriorityStatus(_ status: PowerSyncKotlin.PriorityStatusEntry) -> PriorityStatusEntry { 74 | var lastSyncedAt: Date? 75 | if let syncedAt = status.lastSyncedAt { 76 | lastSyncedAt = Date( 77 | timeIntervalSince1970: Double(syncedAt.epochSeconds) 78 | ) 79 | } 80 | 81 | return PriorityStatusEntry( 82 | priority: BucketPriority(status.priority), 83 | lastSyncedAt: lastSyncedAt, 84 | hasSynced: status.hasSynced?.boolValue 85 | ) 86 | } 87 | } 88 | 89 | protocol KotlinProgressWithOperationsProtocol: ProgressWithOperations { 90 | var base: any PowerSyncKotlin.ProgressWithOperations { get } 91 | } 92 | 93 | extension KotlinProgressWithOperationsProtocol { 94 | var totalOperations: Int32 { 95 | return base.totalOperations 96 | } 97 | 98 | var downloadedOperations: Int32 { 99 | return base.downloadedOperations 100 | } 101 | } 102 | 103 | struct KotlinProgressWithOperations: KotlinProgressWithOperationsProtocol { 104 | let base: PowerSyncKotlin.ProgressWithOperations 105 | } 106 | 107 | struct KotlinSyncDownloadProgress: KotlinProgressWithOperationsProtocol, SyncDownloadProgress { 108 | let progress: PowerSyncKotlin.SyncDownloadProgress 109 | 110 | var base: any PowerSyncKotlin.ProgressWithOperations { 111 | progress 112 | } 113 | 114 | func untilPriority(priority: BucketPriority) -> any ProgressWithOperations { 115 | return KotlinProgressWithOperations(base: progress.untilPriority(priority: priority.priorityCode)) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/PowerSync/Kotlin/wrapQueryCursor.swift: -------------------------------------------------------------------------------- 1 | import PowerSyncKotlin 2 | 3 | /// The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks. 4 | /// If a Swift callback throws an exception, it results in a `BAD ACCESS` crash. 5 | /// 6 | /// This approach is a workaround. Ideally, we should introduce an internal mechanism 7 | /// in the Kotlin SDK to handle errors from Swift more robustly. 8 | /// 9 | /// This hoists any exceptions thrown in a cursor mapper in order for the error to propagate correctly. 10 | /// 11 | /// Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our 12 | /// ability to handle exceptions cleanly. Instead, we should expose an internal implementation 13 | /// from a "core" package in Kotlin that provides better control over exception handling 14 | /// and other functionality—without modifying the public `PowerSyncDatabase` API to include 15 | /// Swift-specific logic. 16 | func wrapQueryCursor( 17 | mapper: @escaping (SqlCursor) throws -> RowType, 18 | // The Kotlin APIs return the results as Any, we can explicitly cast internally 19 | executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) throws -> ReturnType 20 | ) throws -> ReturnType { 21 | var mapperException: Error? 22 | 23 | // Wrapped version of the mapper that catches exceptions and sets `mapperException` 24 | // In the case of an exception this will return an empty result. 25 | let wrappedMapper: (PowerSyncKotlin.SqlCursor) -> RowType? = { cursor in 26 | do { 27 | return try mapper(KotlinSqlCursor( 28 | base: cursor 29 | )) 30 | } catch { 31 | // Store the error in order to propagate it 32 | mapperException = error 33 | // Return nothing here. Kotlin should handle this as an empty object/row 34 | return nil 35 | } 36 | } 37 | 38 | let executionResult = try executor(wrappedMapper) 39 | 40 | if let mapperException { 41 | // Allow propagating the error 42 | throw mapperException 43 | } 44 | 45 | return executionResult 46 | } 47 | 48 | 49 | func wrapQueryCursorTyped( 50 | mapper: @escaping (SqlCursor) throws -> RowType, 51 | // The Kotlin APIs return the results as Any, we can explicitly cast internally 52 | executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) throws -> Any?, 53 | resultType: ReturnType.Type 54 | ) throws -> ReturnType { 55 | return try safeCast( 56 | wrapQueryCursor( 57 | mapper: mapper, 58 | executor: executor 59 | ), to: 60 | resultType 61 | ) 62 | } 63 | 64 | 65 | /// Throws a `PowerSyncException` using a helper provided by the Kotlin SDK. 66 | /// We can't directly throw Kotlin `PowerSyncException`s from Swift, but we can delegate the throwing 67 | /// to the Kotlin implementation. 68 | /// Our Kotlin SDK methods handle thrown Kotlin `PowerSyncException` correctly. 69 | /// The flow of events is as follows 70 | /// Swift code calls `throwKotlinPowerSyncError` 71 | /// This method calls the Kotlin helper `throwPowerSyncException` which is annotated as being able to throw `PowerSyncException` 72 | /// The Kotlin helper throws the provided `PowerSyncException`. Since the method is annotated the exception propagates back to Swift, but in a form which can propagate back 73 | /// to any calling Kotlin stack. 74 | /// This only works for SKIEE methods which have an associated completion handler which handles annotated errors. 75 | /// This seems to only apply for Kotlin suspending function bindings. 76 | func throwKotlinPowerSyncError (message: String, cause: String? = nil) throws { 77 | try throwPowerSyncException( 78 | exception: PowerSyncKotlin.PowerSyncException( 79 | message: message, 80 | cause: PowerSyncKotlin.KotlinThrowable( 81 | message: cause ?? message 82 | ) 83 | ) 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /Sources/PowerSync/Logger.swift: -------------------------------------------------------------------------------- 1 | import OSLog 2 | 3 | /// A log writer which prints to the standard output 4 | /// 5 | /// This writer uses `os.Logger` on iOS/macOS/tvOS/watchOS 14+ and falls back to `print` for earlier versions. 6 | public class PrintLogWriter: LogWriterProtocol { 7 | 8 | private let subsystem: String 9 | private let category: String 10 | private lazy var logger: Any? = { 11 | if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { 12 | return Logger(subsystem: subsystem, category: category) 13 | } 14 | return nil 15 | }() 16 | 17 | /// Creates a new PrintLogWriter 18 | /// - Parameters: 19 | /// - subsystem: The subsystem identifier (typically reverse DNS notation of your app) 20 | /// - category: The category within your subsystem 21 | public init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.powersync.logger", 22 | category: String = "default") { 23 | self.subsystem = subsystem 24 | self.category = category 25 | } 26 | 27 | /// Logs a message with a given severity and optional tag. 28 | /// - Parameters: 29 | /// - severity: The severity level of the message. 30 | /// - message: The content of the log message. 31 | /// - tag: An optional tag used to categorize the message. If empty, no brackets are shown. 32 | public func log(severity: LogSeverity, message: String, tag: String?) { 33 | let tagPrefix = tag.map { !$0.isEmpty ? "[\($0)] " : "" } ?? "" 34 | let formattedMessage = "\(tagPrefix)\(message)" 35 | 36 | if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { 37 | guard let logger = logger as? Logger else { return } 38 | 39 | switch severity { 40 | case .info: 41 | logger.info("\(formattedMessage, privacy: .public)") 42 | case .error: 43 | logger.error("\(formattedMessage, privacy: .public)") 44 | case .debug: 45 | logger.debug("\(formattedMessage, privacy: .public)") 46 | case .warning: 47 | logger.warning("\(formattedMessage, privacy: .public)") 48 | case .fault: 49 | logger.fault("\(formattedMessage, privacy: .public)") 50 | } 51 | } else { 52 | print("\(severity.stringValue): \(formattedMessage)") 53 | } 54 | } 55 | } 56 | 57 | 58 | 59 | /// A default logger configuration that uses `PrintLogWritter` and filters messages by minimum severity. 60 | public class DefaultLogger: LoggerProtocol { 61 | public var minSeverity: LogSeverity 62 | public var writers: [any LogWriterProtocol] 63 | 64 | /// Initializes the default logger with an optional minimum severity level. 65 | /// 66 | /// - Parameters 67 | /// - minSeverity: The minimum severity level to log. Defaults to `.debug`. 68 | /// - writers: Optional writers which logs should be written to. Defaults to a `PrintLogWriter`. 69 | public init(minSeverity: LogSeverity = .debug, writers: [any LogWriterProtocol]? = nil ) { 70 | self.writers = writers ?? [ PrintLogWriter() ] 71 | self.minSeverity = minSeverity 72 | } 73 | 74 | public func setWriters(_ writters: [any LogWriterProtocol]) { 75 | self.writers = writters 76 | } 77 | 78 | public func setMinSeverity(_ severity: LogSeverity) { 79 | self.minSeverity = severity 80 | } 81 | 82 | 83 | public func debug(_ message: String, tag: String? = nil) { 84 | self.writeLog(message, severity: LogSeverity.debug, tag: tag) 85 | } 86 | 87 | public func error(_ message: String, tag: String? = nil) { 88 | self.writeLog(message, severity: LogSeverity.error, tag: tag) 89 | } 90 | 91 | public func info(_ message: String, tag: String? = nil) { 92 | self.writeLog(message, severity: LogSeverity.info, tag: tag) 93 | } 94 | 95 | public func warning(_ message: String, tag: String? = nil) { 96 | self.writeLog(message, severity: LogSeverity.warning, tag: tag) 97 | } 98 | 99 | public func fault(_ message: String, tag: String? = nil) { 100 | self.writeLog(message, severity: LogSeverity.fault, tag: tag) 101 | } 102 | 103 | private func writeLog(_ message: String, severity: LogSeverity, tag: String?) { 104 | if (severity.rawValue < self.minSeverity.rawValue) { 105 | return 106 | } 107 | 108 | for writer in self.writers { 109 | writer.log(severity: severity, message: message, tag: tag) 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/PowerSync/PowerSyncCredentials.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// 5 | /// Temporary credentials to connect to the PowerSync service. 6 | /// 7 | public struct PowerSyncCredentials: Codable { 8 | /// PowerSync endpoint, e.g. "https://myinstance.powersync.co". 9 | public let endpoint: String 10 | 11 | /// Temporary token to authenticate against the service. 12 | public let token: String 13 | 14 | /// User ID. 15 | @available(*, deprecated, message: "This value is not used anymore.") 16 | public let userId: String? = nil 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case endpoint 20 | case token 21 | } 22 | 23 | @available(*, deprecated, message: "Use init(endpoint:token:) instead. `userId` is no longer used.") 24 | public init( 25 | endpoint: String, 26 | token: String, 27 | userId: String? = nil) { 28 | self.endpoint = endpoint 29 | self.token = token 30 | } 31 | 32 | public init(endpoint: String, token: String) { 33 | self.endpoint = endpoint 34 | self.token = token 35 | } 36 | 37 | internal init(kotlin: KotlinPowerSyncCredentials) { 38 | self.endpoint = kotlin.endpoint 39 | self.token = kotlin.token 40 | } 41 | 42 | internal var kotlinCredentials: KotlinPowerSyncCredentials { 43 | return KotlinPowerSyncCredentials(endpoint: endpoint, token: token, userId: nil) 44 | } 45 | 46 | public func endpointUri(path: String) -> String { 47 | return "\(endpoint)/\(path)" 48 | } 49 | } 50 | 51 | extension PowerSyncCredentials: CustomStringConvertible { 52 | public var description: String { 53 | return "PowerSyncCredentials" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/PowerSync/PowerSyncDatabase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Default database filename 4 | public let DEFAULT_DB_FILENAME = "powersync.db" 5 | 6 | /// Creates a PowerSyncDatabase instance 7 | /// - Parameters: 8 | /// - schema: The database schema 9 | /// - dbFilename: The database filename. Defaults to "powersync.db" 10 | /// - logger: Optional logging interface 11 | /// - Returns: A configured PowerSyncDatabase instance 12 | public func PowerSyncDatabase( 13 | schema: Schema, 14 | dbFilename: String = DEFAULT_DB_FILENAME, 15 | logger: (any LoggerProtocol) = DefaultLogger() 16 | ) -> PowerSyncDatabaseProtocol { 17 | 18 | return KotlinPowerSyncDatabaseImpl( 19 | schema: schema, 20 | dbFilename: dbFilename, 21 | logger: DatabaseLogger(logger) 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/LoggerProtocol.swift: -------------------------------------------------------------------------------- 1 | public enum LogSeverity: Int, CaseIterable { 2 | /// Detailed information typically used for debugging. 3 | case debug = 0 4 | 5 | /// Informational messages that highlight the progress of the application. 6 | case info = 1 7 | 8 | /// Potentially harmful situations that are not necessarily errors. 9 | case warning = 2 10 | 11 | /// Error events that might still allow the application to continue running. 12 | case error = 3 13 | 14 | /// Serious errors indicating critical failures, often unrecoverable. 15 | case fault = 4 16 | 17 | /// Map severity to its string representation 18 | public var stringValue: String { 19 | switch self { 20 | case .debug: return "DEBUG" 21 | case .info: return "INFO" 22 | case .warning: return "WARNING" 23 | case .error: return "ERROR" 24 | case .fault: return "FAULT" 25 | } 26 | } 27 | 28 | /// Convert Int to String representation 29 | public static func string(from intValue: Int) -> String? { 30 | return LogSeverity(rawValue: intValue)?.stringValue 31 | } 32 | } 33 | 34 | /// A protocol for writing log messages to a specific backend or output. 35 | /// 36 | /// Conformers handle the actual writing or forwarding of log messages. 37 | public protocol LogWriterProtocol { 38 | /// Logs a message with the given severity and optional tag. 39 | /// 40 | /// - Parameters: 41 | /// - severity: The severity level of the log message. 42 | /// - message: The content of the log message. 43 | /// - tag: An optional tag to categorize or group the log message. 44 | func log(severity: LogSeverity, message: String, tag: String?) 45 | } 46 | 47 | /// A protocol defining the interface for a logger that supports severity filtering and multiple writers. 48 | /// 49 | /// Conformers provide logging APIs and manage attached log writers. 50 | public protocol LoggerProtocol { 51 | /// Logs an informational message. 52 | /// 53 | /// - Parameters: 54 | /// - message: The content of the log message. 55 | /// - tag: An optional tag to categorize the message. 56 | func info(_ message: String, tag: String?) 57 | 58 | /// Logs an error message. 59 | /// 60 | /// - Parameters: 61 | /// - message: The content of the log message. 62 | /// - tag: An optional tag to categorize the message. 63 | func error(_ message: String, tag: String?) 64 | 65 | /// Logs a debug message. 66 | /// 67 | /// - Parameters: 68 | /// - message: The content of the log message. 69 | /// - tag: An optional tag to categorize the message. 70 | func debug(_ message: String, tag: String?) 71 | 72 | /// Logs a warning message. 73 | /// 74 | /// - Parameters: 75 | /// - message: The content of the log message. 76 | /// - tag: An optional tag to categorize the message. 77 | func warning(_ message: String, tag: String?) 78 | 79 | /// Logs a fault message, typically used for critical system-level failures. 80 | /// 81 | /// - Parameters: 82 | /// - message: The content of the log message. 83 | /// - tag: An optional tag to categorize the message. 84 | func fault(_ message: String, tag: String?) 85 | } 86 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/PowerSyncBackendConnector.swift: -------------------------------------------------------------------------------- 1 | public protocol PowerSyncBackendConnectorProtocol { 2 | /// 3 | /// Get credentials for PowerSync. 4 | /// 5 | /// This should always fetch a fresh set of credentials - don't use cached 6 | /// values. 7 | /// 8 | /// Return null if the user is not signed in. Throw an error if credentials 9 | /// cannot be fetched due to a network error or other temporary error. 10 | /// 11 | /// This token is kept for the duration of a sync connection. 12 | /// 13 | func fetchCredentials() async throws -> PowerSyncCredentials? 14 | 15 | /// 16 | /// Upload local changes to the app backend. 17 | /// 18 | /// Use [getCrudBatch] to get a batch of changes to upload. 19 | /// 20 | /// Any thrown errors will result in a retry after the configured wait period (default: 5 seconds). 21 | /// 22 | func uploadData(database: PowerSyncDatabaseProtocol) async throws 23 | } 24 | 25 | /// Implement this to connect an app backend. 26 | /// 27 | /// The connector is responsible for: 28 | /// 1. Creating credentials for connecting to the PowerSync service. 29 | /// 2. Applying local changes against the backend application server. 30 | /// 31 | /// 32 | open class PowerSyncBackendConnector: PowerSyncBackendConnectorProtocol { 33 | public init() {} 34 | 35 | open func fetchCredentials() async throws -> PowerSyncCredentials? { 36 | return nil 37 | } 38 | 39 | open func uploadData(database: PowerSyncDatabaseProtocol) async throws {} 40 | } 41 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/PowerSyncError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Enum representing errors that can occur in the PowerSync system. 4 | public enum PowerSyncError: Error, LocalizedError { 5 | 6 | /// Represents a failure in an operation, potentially with a custom message and an underlying error. 7 | case operationFailed(message: String? = nil, underlyingError: Error? = nil) 8 | 9 | /// A localized description of the error, providing details about the failure. 10 | public var errorDescription: String? { 11 | switch self { 12 | case let .operationFailed(message, underlyingError): 13 | // Combine message and underlying error description if both are available 14 | if let message = message, let underlyingError = underlyingError { 15 | return "\(message): \(underlyingError.localizedDescription)" 16 | } else if let message = message { 17 | // Return only the message if no underlying error is available 18 | return message 19 | } else if let underlyingError = underlyingError { 20 | // Return only the underlying error description if no message is provided 21 | return underlyingError.localizedDescription 22 | } else { 23 | // Fallback to a generic error description if neither message nor underlying error is provided 24 | return "An unknown error occurred." 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/QueriesProtocol.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public let DEFAULT_WATCH_THROTTLE: TimeInterval = 0.03 // 30ms 5 | 6 | public struct WatchOptions { 7 | public var sql: String 8 | public var parameters: [Any?] 9 | public var throttle: TimeInterval 10 | public var mapper: (SqlCursor) throws -> RowType 11 | 12 | public init( 13 | sql: String, parameters: [Any?]? = [], 14 | throttle: TimeInterval? = DEFAULT_WATCH_THROTTLE, 15 | mapper: @escaping (SqlCursor) throws -> RowType 16 | ) { 17 | self.sql = sql 18 | self.parameters = parameters ?? [] 19 | self.throttle = throttle ?? DEFAULT_WATCH_THROTTLE 20 | self.mapper = mapper 21 | } 22 | } 23 | 24 | public protocol Queries { 25 | /// Execute a write query (INSERT, UPDATE, DELETE) 26 | /// Using `RETURNING *` will result in an error. 27 | @discardableResult 28 | func execute(sql: String, parameters: [Any?]?) async throws -> Int64 29 | 30 | /// Execute a read-only (SELECT) query and return a single result. 31 | /// If there is no result, throws an IllegalArgumentException. 32 | /// See `getOptional` for queries where the result might be empty. 33 | func get( 34 | sql: String, 35 | parameters: [Any?]?, 36 | mapper: @escaping (SqlCursor) throws -> RowType 37 | ) async throws -> RowType 38 | 39 | /// Execute a read-only (SELECT) query and return the results. 40 | func getAll( 41 | sql: String, 42 | parameters: [Any?]?, 43 | mapper: @escaping (SqlCursor) throws -> RowType 44 | ) async throws -> [RowType] 45 | 46 | /// Execute a read-only (SELECT) query and return a single optional result. 47 | func getOptional( 48 | sql: String, 49 | parameters: [Any?]?, 50 | mapper: @escaping (SqlCursor) throws -> RowType 51 | ) async throws -> RowType? 52 | 53 | /// Execute a read-only (SELECT) query every time the source tables are modified 54 | /// and return the results as an array in a Publisher. 55 | func watch( 56 | sql: String, 57 | parameters: [Any?]?, 58 | mapper: @escaping (SqlCursor) throws -> RowType 59 | ) throws -> AsyncThrowingStream<[RowType], Error> 60 | 61 | func watch( 62 | options: WatchOptions 63 | ) throws -> AsyncThrowingStream<[RowType], Error> 64 | 65 | /// Takes a global lock, without starting a transaction. 66 | /// 67 | /// In most cases, [writeTransaction] should be used instead. 68 | func writeLock( 69 | callback: @escaping (any ConnectionContext) throws -> R 70 | ) async throws -> R 71 | 72 | /// Takes a read lock, without starting a transaction. 73 | /// 74 | /// The lock only applies to a single connection, and multiple 75 | /// connections may hold read locks at the same time. 76 | func readLock( 77 | callback: @escaping (any ConnectionContext) throws -> R 78 | ) async throws -> R 79 | 80 | /// Execute a write transaction with the given callback 81 | func writeTransaction( 82 | callback: @escaping (any Transaction) throws -> R 83 | ) async throws -> R 84 | 85 | /// Execute a read transaction with the given callback 86 | func readTransaction( 87 | callback: @escaping (any Transaction) throws -> R 88 | ) async throws -> R 89 | } 90 | 91 | public extension Queries { 92 | @discardableResult 93 | func execute(_ sql: String) async throws -> Int64 { 94 | return try await execute(sql: sql, parameters: []) 95 | } 96 | 97 | func get( 98 | _ sql: String, 99 | mapper: @escaping (SqlCursor) throws -> RowType 100 | ) async throws -> RowType { 101 | return try await get(sql: sql, parameters: [], mapper: mapper) 102 | } 103 | 104 | func getAll( 105 | _ sql: String, 106 | mapper: @escaping (SqlCursor) throws -> RowType 107 | ) async throws -> [RowType] { 108 | return try await getAll(sql: sql, parameters: [], mapper: mapper) 109 | } 110 | 111 | func getOptional( 112 | _ sql: String, 113 | mapper: @escaping (SqlCursor) throws -> RowType 114 | ) async throws -> RowType? { 115 | return try await getOptional(sql: sql, parameters: [], mapper: mapper) 116 | } 117 | 118 | func watch( 119 | _ sql: String, 120 | mapper: @escaping (SqlCursor) throws -> RowType 121 | ) throws -> AsyncThrowingStream<[RowType], Error> { 122 | return try watch(sql: sql, parameters: [Any?](), mapper: mapper) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/Schema/Column.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSyncKotlin 3 | 4 | public protocol ColumnProtocol: Equatable { 5 | /// Name of the column. 6 | var name: String { get } 7 | /// Type of the column. 8 | /// 9 | /// If the underlying data does not match this type, 10 | /// it is cast automatically. 11 | /// 12 | /// For details on the cast, see: 13 | /// https://www.sqlite.org/lang_expr.html#castexpr 14 | /// 15 | var type: ColumnData { get } 16 | } 17 | 18 | public enum ColumnData { 19 | case text 20 | case integer 21 | case real 22 | } 23 | 24 | /// A single column in a table schema. 25 | public struct Column: ColumnProtocol { 26 | public let name: String 27 | public let type: ColumnData 28 | 29 | public init( 30 | name: String, 31 | type: ColumnData 32 | ) { 33 | self.name = name 34 | self.type = type 35 | } 36 | 37 | public static func text(_ name: String) -> Column { 38 | Column(name: name, type: .text) 39 | } 40 | 41 | public static func integer(_ name: String) -> Column { 42 | Column(name: name, type: .integer) 43 | } 44 | 45 | public static func real(_ name: String) -> Column { 46 | Column(name: name, type: .real) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/Schema/Index.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PowerSyncKotlin 3 | 4 | public protocol IndexProtocol { 5 | /// 6 | /// Descriptive name of the index. 7 | /// 8 | var name: String { get } 9 | /// 10 | /// List of columns used for the index. 11 | /// 12 | var columns: [IndexedColumnProtocol] { get } 13 | } 14 | 15 | public struct Index: IndexProtocol { 16 | public let name: String 17 | public let columns: [IndexedColumnProtocol] 18 | 19 | public init( 20 | name: String, 21 | columns: [IndexedColumnProtocol] 22 | ) { 23 | self.name = name 24 | self.columns = columns 25 | } 26 | 27 | public init( 28 | name: String, 29 | _ columns: IndexedColumnProtocol... 30 | ) { 31 | self.init(name: name, columns: columns) 32 | } 33 | 34 | public static func ascending( 35 | name: String, 36 | columns: [String] 37 | ) -> Index { 38 | return Index( 39 | name: name, 40 | columns: columns.map { IndexedColumn.ascending($0) } 41 | ) 42 | } 43 | 44 | public static func ascending( 45 | name: String, 46 | column: String 47 | ) -> Index { 48 | return ascending(name: name, columns: [column]) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/Schema/IndexedColumn.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 4 | /// Describes an indexed column. 5 | /// 6 | public protocol IndexedColumnProtocol { 7 | /// 8 | /// Name of the column to index. 9 | /// 10 | var column: String { get } 11 | /// 12 | /// Whether this column is stored in ascending order in the index. 13 | /// 14 | var ascending: Bool { get } 15 | } 16 | 17 | public struct IndexedColumn: IndexedColumnProtocol { 18 | public let column: String 19 | public let ascending: Bool 20 | 21 | public init( 22 | column: String, 23 | ascending: Bool = true 24 | ) { 25 | self.column = column 26 | self.ascending = ascending 27 | } 28 | 29 | /// 30 | /// Creates ascending IndexedColumn 31 | /// 32 | public static func ascending(_ column: String) -> IndexedColumn { 33 | IndexedColumn(column: column, ascending: true) 34 | } 35 | 36 | /// 37 | /// Creates descending IndexedColumn 38 | /// 39 | public static func descending(_ column: String) -> IndexedColumn { 40 | IndexedColumn(column: column, ascending: false) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/Schema/Schema.swift: -------------------------------------------------------------------------------- 1 | public protocol SchemaProtocol { 2 | /// 3 | /// Tables used in Schema 4 | /// 5 | var tables: [Table] { get } 6 | /// 7 | /// Validate tables 8 | /// 9 | func validate() throws 10 | } 11 | 12 | public struct Schema: SchemaProtocol { 13 | public let tables: [Table] 14 | 15 | public init(tables: [Table]) { 16 | self.tables = tables 17 | } 18 | /// 19 | /// Convenience initializer with variadic parameters 20 | /// 21 | public init(_ tables: Table...) { 22 | self.init(tables: tables) 23 | } 24 | 25 | public func validate() throws { 26 | var tableNames = Set() 27 | 28 | for table in tables { 29 | if !tableNames.insert(table.name).inserted { 30 | throw SchemaError.duplicateTableName(table.name) 31 | } 32 | try table.validate() 33 | } 34 | } 35 | } 36 | 37 | public enum SchemaError: Error { 38 | case duplicateTableName(String) 39 | } 40 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/Schema/Table.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol TableProtocol { 4 | /// 5 | /// The synced table name, matching sync rules. 6 | /// 7 | var name: String { get } 8 | /// 9 | /// List of columns. 10 | /// 11 | var columns: [Column] { get } 12 | /// 13 | /// List of indexes. 14 | /// 15 | var indexes: [Index] { get } 16 | /// 17 | /// Whether the table only exists locally. 18 | /// 19 | var localOnly: Bool { get } 20 | /// 21 | /// Whether this is an insert-only table. 22 | /// 23 | var insertOnly: Bool { get } 24 | /// 25 | /// Override the name for the view 26 | /// 27 | var viewNameOverride: String? { get } 28 | var viewName: String { get } 29 | 30 | /// Whether to add a hidden `_metadata` column that will ne abled for updates to 31 | /// attach custom information about writes. 32 | /// 33 | /// When the `_metadata` column is written to for inserts or updates, its value will not be 34 | /// part of ``CrudEntry/opData``. Instead, it is reported as ``CrudEntry/metadata``, 35 | /// allowing ``PowerSyncBackendConnector``s to handle these updates specially. 36 | var trackMetadata: Bool { get } 37 | 38 | /// When set to a non-`nil` value, track old values of columns for ``CrudEntry/previousValues``. 39 | /// 40 | /// See ``TrackPreviousValuesOptions`` for details 41 | var trackPreviousValues: TrackPreviousValuesOptions? { get } 42 | 43 | /// Whether an `UPDATE` statement that doesn't change any values should be ignored entirely when 44 | /// creating CRUD entries. 45 | /// 46 | /// This is disabled by default, meaning that an `UPDATE` on a row that doesn't change values would 47 | /// create a ``CrudEntry`` with an empty ``CrudEntry/opData`` and ``UpdateType/patch``. 48 | var ignoreEmptyUpdates: Bool { get } 49 | } 50 | 51 | /// Options to include old values in ``CrudEntry/previousValues`` for update statements. 52 | /// 53 | /// These options are enabled by passing them to a non-local ``Table`` constructor. 54 | public struct TrackPreviousValuesOptions { 55 | /// A filter of column names for which updates should be tracked. 56 | /// 57 | /// When set to a non-`nil` value, columns not included in this list will not appear in 58 | /// ``CrudEntry/previousValues``. By default, all columns are included. 59 | public let columnFilter: [String]?; 60 | 61 | /// Whether to only include old values when they were changed by an update, instead of always including 62 | /// all old values. 63 | public let onlyWhenChanged: Bool; 64 | 65 | public init(columnFilter: [String]? = nil, onlyWhenChanged: Bool = false) { 66 | self.columnFilter = columnFilter 67 | self.onlyWhenChanged = onlyWhenChanged 68 | } 69 | } 70 | 71 | private let MAX_AMOUNT_OF_COLUMNS = 63 72 | 73 | /// 74 | /// A single table in the schema. 75 | /// 76 | public struct Table: TableProtocol { 77 | public let name: String 78 | public let columns: [Column] 79 | public let indexes: [Index] 80 | public let localOnly: Bool 81 | public let insertOnly: Bool 82 | public let viewNameOverride: String? 83 | public let trackMetadata: Bool 84 | public let trackPreviousValues: TrackPreviousValuesOptions? 85 | public let ignoreEmptyUpdates: Bool 86 | 87 | public var viewName: String { 88 | viewNameOverride ?? name 89 | } 90 | 91 | internal var internalName: String { 92 | localOnly ? "ps_data_local__\(name)" : "ps_data__\(name)" 93 | } 94 | 95 | private let invalidSqliteCharacters = try! NSRegularExpression( 96 | pattern: #"["'%,.#\s\[\]]"#, 97 | options: [] 98 | ) 99 | 100 | public init( 101 | name: String, 102 | columns: [Column], 103 | indexes: [Index] = [], 104 | localOnly: Bool = false, 105 | insertOnly: Bool = false, 106 | viewNameOverride: String? = nil, 107 | trackMetadata: Bool = false, 108 | trackPreviousValues: TrackPreviousValuesOptions? = nil, 109 | ignoreEmptyUpdates: Bool = false 110 | ) { 111 | self.name = name 112 | self.columns = columns 113 | self.indexes = indexes 114 | self.localOnly = localOnly 115 | self.insertOnly = insertOnly 116 | self.viewNameOverride = viewNameOverride 117 | self.trackMetadata = trackMetadata 118 | self.trackPreviousValues = trackPreviousValues 119 | self.ignoreEmptyUpdates = ignoreEmptyUpdates 120 | } 121 | 122 | private func hasInvalidSqliteCharacters(_ string: String) -> Bool { 123 | let range = NSRange(location: 0, length: string.utf16.count) 124 | return invalidSqliteCharacters.firstMatch(in: string, options: [], range: range) != nil 125 | } 126 | 127 | /// 128 | /// Validate the table 129 | /// 130 | public func validate() throws { 131 | if columns.count > MAX_AMOUNT_OF_COLUMNS { 132 | throw TableError.tooManyColumns(tableName: name, count: columns.count) 133 | } 134 | 135 | if let viewNameOverride = viewNameOverride, 136 | hasInvalidSqliteCharacters(viewNameOverride) { 137 | throw TableError.invalidViewName(viewName: viewNameOverride) 138 | } 139 | 140 | if localOnly { 141 | if trackPreviousValues != nil { 142 | throw TableError.trackPreviousForLocalTable(tableName: name) 143 | } 144 | if trackMetadata { 145 | throw TableError.metadataForLocalTable(tableName: name) 146 | } 147 | } 148 | 149 | var columnNames = Set(["id"]) 150 | 151 | for column in columns { 152 | if column.name == "id" { 153 | throw TableError.customIdColumn(tableName: name) 154 | } 155 | 156 | if columnNames.contains(column.name) { 157 | throw TableError.duplicateColumn( 158 | tableName: name, 159 | columnName: column.name 160 | ) 161 | } 162 | 163 | if hasInvalidSqliteCharacters(column.name) { 164 | throw TableError.invalidColumnName( 165 | tableName: name, 166 | columnName: column.name 167 | ) 168 | } 169 | 170 | columnNames.insert(column.name) 171 | } 172 | 173 | // Check indexes 174 | var indexNames = Set() 175 | 176 | for index in indexes { 177 | if indexNames.contains(index.name) { 178 | throw TableError.duplicateIndex( 179 | tableName: name, 180 | indexName: index.name 181 | ) 182 | } 183 | 184 | if hasInvalidSqliteCharacters(index.name) { 185 | throw TableError.invalidIndexName( 186 | tableName: name, 187 | indexName: index.name 188 | ) 189 | } 190 | 191 | // Check index columns exist in table 192 | for indexColumn in index.columns { 193 | if !columnNames.contains(indexColumn.column) { 194 | throw TableError.columnNotFound( 195 | tableName: name, 196 | columnName: indexColumn.column, 197 | indexName: index.name 198 | ) 199 | } 200 | } 201 | 202 | indexNames.insert(index.name) 203 | } 204 | } 205 | } 206 | 207 | public enum TableError: Error { 208 | case tooManyColumns(tableName: String, count: Int) 209 | case invalidTableName(tableName: String) 210 | case invalidViewName(viewName: String) 211 | case invalidColumnName(tableName: String, columnName: String) 212 | case duplicateColumn(tableName: String, columnName: String) 213 | case customIdColumn(tableName: String) 214 | case duplicateIndex(tableName: String, indexName: String) 215 | case invalidIndexName(tableName: String, indexName: String) 216 | case columnNotFound(tableName: String, columnName: String, indexName: String) 217 | /// Local-only tables can't enable ``Table/trackMetadata`` because no updates are tracked for those tables at all. 218 | case metadataForLocalTable(tableName: String) 219 | /// Local-only tables can't enable ``Table/trackPreviousValues`` because no updates are tracked for those tables at all. 220 | case trackPreviousForLocalTable(tableName: String) 221 | } 222 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/db/ConnectionContext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ConnectionContext { 4 | /** 5 | Executes a SQL statement with optional parameters. 6 | 7 | - Parameters: 8 | - sql: The SQL statement to execute 9 | - parameters: Optional list of parameters for the SQL statement 10 | 11 | - Returns: A value indicating the number of rows affected 12 | 13 | - Throws: PowerSyncError if execution fails 14 | */ 15 | @discardableResult 16 | func execute(sql: String, parameters: [Any?]?) throws -> Int64 17 | 18 | /** 19 | Retrieves an optional value from the database using the provided SQL query. 20 | 21 | - Parameters: 22 | - sql: The SQL query to execute 23 | - parameters: Optional list of parameters for the SQL query 24 | - mapper: A closure that maps the SQL cursor result to the desired type 25 | 26 | - Returns: An optional value of type RowType or nil if no result 27 | 28 | - Throws: PowerSyncError if the query fails 29 | */ 30 | func getOptional( 31 | sql: String, 32 | parameters: [Any?]?, 33 | mapper: @escaping (SqlCursor) throws -> RowType 34 | ) throws -> RowType? 35 | 36 | /** 37 | Retrieves all matching rows from the database using the provided SQL query. 38 | 39 | - Parameters: 40 | - sql: The SQL query to execute 41 | - parameters: Optional list of parameters for the SQL query 42 | - mapper: A closure that maps each SQL cursor result to the desired type 43 | 44 | - Returns: An array of RowType objects 45 | 46 | - Throws: PowerSyncError if the query fails 47 | */ 48 | func getAll( 49 | sql: String, 50 | parameters: [Any?]?, 51 | mapper: @escaping (SqlCursor) throws -> RowType 52 | ) throws -> [RowType] 53 | 54 | /** 55 | Retrieves a single value from the database using the provided SQL query. 56 | 57 | - Parameters: 58 | - sql: The SQL query to execute 59 | - parameters: Optional list of parameters for the SQL query 60 | - mapper: A closure that maps the SQL cursor result to the desired type 61 | 62 | - Returns: A value of type RowType 63 | 64 | - Throws: PowerSyncError if the query fails or no result is found 65 | */ 66 | func get( 67 | sql: String, 68 | parameters: [Any?]?, 69 | mapper: @escaping (SqlCursor) throws -> RowType 70 | ) throws -> RowType 71 | } 72 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/db/CrudBatch.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A transaction of client-side changes. 4 | public protocol CrudBatch { 5 | /// Indicates if there are additional Crud items in the queue which are not included in this batch 6 | var hasMore: Bool { get } 7 | 8 | /// List of client-side changes. 9 | var crud: [any CrudEntry] { get } 10 | 11 | /// Call to remove the changes from the local queue, once successfully uploaded. 12 | /// 13 | /// `writeCheckpoint` is optional. 14 | func complete(writeCheckpoint: String?) async throws 15 | } 16 | 17 | public extension CrudBatch { 18 | /// Call to remove the changes from the local queue, once successfully uploaded. 19 | func complete() async throws { 20 | try await self.complete( 21 | writeCheckpoint: nil 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/db/CrudEntry.swift: -------------------------------------------------------------------------------- 1 | /// Represents the type of CRUD update operation that can be performed on a row. 2 | public enum UpdateType: String, Codable { 3 | /// A row has been inserted or replaced 4 | case put = "PUT" 5 | 6 | /// A row has been updated 7 | case patch = "PATCH" 8 | 9 | /// A row has been deleted 10 | case delete = "DELETE" 11 | 12 | /// Errors related to invalid `UpdateType` states. 13 | enum UpdateTypeStateError: Error { 14 | /// Indicates an invalid state with the provided string value. 15 | case invalidState(String) 16 | } 17 | 18 | /// Converts a string to an `UpdateType` enum value. 19 | /// - Parameter input: The string representation of the update type. 20 | /// - Throws: `UpdateTypeStateError.invalidState` if the input string does not match any `UpdateType`. 21 | /// - Returns: The corresponding `UpdateType` enum value. 22 | static func fromString(_ input: String) throws -> UpdateType { 23 | guard let mapped = UpdateType.init(rawValue: input) else { 24 | throw UpdateTypeStateError.invalidState(input) 25 | } 26 | return mapped 27 | } 28 | } 29 | 30 | /// Represents a CRUD (Create, Read, Update, Delete) entry in the system. 31 | public protocol CrudEntry { 32 | /// The unique identifier of the entry. 33 | var id: String { get } 34 | 35 | /// The client ID associated with the entry. 36 | var clientId: Int64 { get } 37 | 38 | /// The type of update operation performed on the entry. 39 | var op: UpdateType { get } 40 | 41 | /// The name of the table where the entry resides. 42 | var table: String { get } 43 | 44 | /// The transaction ID associated with the entry, if any. 45 | var transactionId: Int64? { get } 46 | 47 | /// User-defined metadata that can be attached to writes. 48 | /// 49 | /// This is the value the `_metadata` column had when the write to the database was made, 50 | /// allowing backend connectors to e.g. identify a write and tear it specially. 51 | /// 52 | /// Note that the `_metadata` column and this field are only available when ``Table/trackMetadata`` 53 | /// is enabled. 54 | var metadata: String? { get } 55 | 56 | /// The operation data associated with the entry, represented as a dictionary of column names to their values. 57 | var opData: [String: String?]? { get } 58 | 59 | /// Previous values before this change. 60 | /// 61 | /// These values can be tracked for `UPDATE` statements when ``Table/trackPreviousValues`` is enabled. 62 | var previousValues: [String: String?]? { get } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/db/CrudTransaction.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A transaction of client-side changes. 4 | public protocol CrudTransaction { 5 | /// Unique transaction id. 6 | /// 7 | /// If nil, this contains a list of changes recorded without an explicit transaction associated. 8 | var transactionId: Int64? { get } 9 | 10 | /// List of client-side changes. 11 | var crud: [any CrudEntry] { get } 12 | 13 | /// Call to remove the changes from the local queue, once successfully uploaded. 14 | /// 15 | /// `writeCheckpoint` is optional. 16 | func complete(writeCheckpoint: String?) async throws 17 | } 18 | 19 | public extension CrudTransaction { 20 | /// Call to remove the changes from the local queue, once successfully uploaded. 21 | func complete() async throws { 22 | try await self.complete( 23 | writeCheckpoint: nil 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/db/JsonParam.swift: -------------------------------------------------------------------------------- 1 | /// A strongly-typed representation of a JSON value. 2 | /// 3 | /// Supports all standard JSON types: string, number (integer and double), 4 | /// boolean, null, arrays, and nested objects. 5 | public enum JsonValue: Codable { 6 | /// A JSON string value. 7 | case string(String) 8 | 9 | /// A JSON integer value. 10 | case int(Int) 11 | 12 | /// A JSON double-precision floating-point value. 13 | case double(Double) 14 | 15 | /// A JSON boolean value (`true` or `false`). 16 | case bool(Bool) 17 | 18 | /// A JSON null value. 19 | case null 20 | 21 | /// A JSON array containing a list of `JSONValue` elements. 22 | case array([JsonValue]) 23 | 24 | /// A JSON object containing key-value pairs where values are `JSONValue` instances. 25 | case object([String: JsonValue]) 26 | 27 | /// Converts the `JSONValue` into a native Swift representation. 28 | /// 29 | /// - Returns: A corresponding Swift type (`String`, `Int`, `Double`, `Bool`, `nil`, `[Any]`, or `[String: Any]`), 30 | /// or `nil` if the value is `.null`. 31 | func toValue() -> Any? { 32 | switch self { 33 | case .string(let value): 34 | return value 35 | case .int(let value): 36 | return value 37 | case .double(let value): 38 | return value 39 | case .bool(let value): 40 | return value 41 | case .null: 42 | return nil 43 | case .array(let array): 44 | return array.map { $0.toValue() } 45 | case .object(let dict): 46 | var anyDict: [String: Any] = [:] 47 | for (key, value) in dict { 48 | anyDict[key] = value.toValue() 49 | } 50 | return anyDict 51 | } 52 | } 53 | } 54 | 55 | /// A typealias representing a top-level JSON object with string keys and `JSONValue` values. 56 | public typealias JsonParam = [String: JsonValue] 57 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/db/SqlCursor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol representing a cursor for SQL query results, providing methods to retrieve values by column index or name. 4 | public protocol SqlCursor { 5 | /// Retrieves a `Bool` value from the specified column name. 6 | /// - Parameter name: The name of the column. 7 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 8 | /// - Returns: The `Bool` value. 9 | func getBoolean(index: Int) throws -> Bool 10 | 11 | /// Retrieves a `Bool` value from the specified column index. 12 | /// - Parameter index: The zero-based index of the column. 13 | /// - Returns: The `Bool` value if present, or `nil` if the value is null. 14 | func getBooleanOptional(index: Int) -> Bool? 15 | 16 | /// Retrieves a `Bool` value from the specified column name. 17 | /// - Parameter name: The name of the column. 18 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 19 | /// - Returns: The `Bool` value. 20 | func getBoolean(name: String) throws -> Bool 21 | 22 | /// Retrieves an optional `Bool` value from the specified column name. 23 | /// - Parameter name: The name of the column. 24 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. 25 | /// - Returns: The `Bool` value if present, or `nil` if the value is null. 26 | func getBooleanOptional(name: String) throws -> Bool? 27 | 28 | /// Retrieves a `Double` value from the specified column name. 29 | /// - Parameter name: The name of the column. 30 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 31 | /// - Returns: The `Double` value. 32 | func getDouble(index: Int) throws -> Double 33 | 34 | /// Retrieves a `Double` value from the specified column index. 35 | /// - Parameter index: The zero-based index of the column. 36 | /// - Returns: The `Double` value if present, or `nil` if the value is null. 37 | func getDoubleOptional(index: Int) -> Double? 38 | 39 | /// Retrieves a `Double` value from the specified column name. 40 | /// - Parameter name: The name of the column. 41 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 42 | /// - Returns: The `Double` value. 43 | func getDouble(name: String) throws -> Double 44 | 45 | /// Retrieves an optional `Double` value from the specified column name. 46 | /// - Parameter name: The name of the column. 47 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. 48 | /// - Returns: The `Double` value if present, or `nil` if the value is null. 49 | func getDoubleOptional(name: String) throws -> Double? 50 | 51 | /// Retrieves an `Int` value from the specified column name. 52 | /// - Parameter name: The name of the column. 53 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 54 | /// - Returns: The `Int` value. 55 | func getInt(index: Int) throws -> Int 56 | 57 | /// Retrieves an `Int` value from the specified column index. 58 | /// - Parameter index: The zero-based index of the column. 59 | /// - Returns: The `Int` value if present, or `nil` if the value is null. 60 | func getIntOptional(index: Int) -> Int? 61 | 62 | /// Retrieves an `Int` value from the specified column name. 63 | /// - Parameter name: The name of the column. 64 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 65 | /// - Returns: The `Int` value. 66 | func getInt(name: String) throws -> Int 67 | 68 | /// Retrieves an optional `Int` value from the specified column name. 69 | /// - Parameter name: The name of the column. 70 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. 71 | /// - Returns: The `Int` value if present, or `nil` if the value is null. 72 | func getIntOptional(name: String) throws -> Int? 73 | 74 | /// Retrieves an `Int64` value from the specified column name. 75 | /// - Parameter name: The name of the column. 76 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 77 | /// - Returns: The `Int64` value. 78 | func getInt64(index: Int) throws -> Int64 79 | 80 | /// Retrieves an `Int64` value from the specified column index. 81 | /// - Parameter index: The zero-based index of the column. 82 | /// - Returns: The `Int64` value if present, or `nil` if the value is null. 83 | func getInt64Optional(index: Int) -> Int64? 84 | 85 | /// Retrieves an `Int64` value from the specified column name. 86 | /// - Parameter name: The name of the column. 87 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 88 | /// - Returns: The `Int64` value. 89 | func getInt64(name: String) throws -> Int64 90 | 91 | /// Retrieves an optional `Int64` value from the specified column name. 92 | /// - Parameter name: The name of the column. 93 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. 94 | /// - Returns: The `Int64` value if present, or `nil` if the value is null. 95 | func getInt64Optional(name: String) throws -> Int64? 96 | 97 | /// Retrieves a `String` value from the specified column name. 98 | /// - Parameter name: The name of the column. 99 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 100 | /// - Returns: The `String` value. 101 | func getString(index: Int) throws -> String 102 | 103 | /// Retrieves a `String` value from the specified column index. 104 | /// - Parameter index: The zero-based index of the column. 105 | /// - Returns: The `String` value if present, or `nil` if the value is null. 106 | func getStringOptional(index: Int) -> String? 107 | 108 | /// Retrieves a `String` value from the specified column name. 109 | /// - Parameter name: The name of the column. 110 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. 111 | /// - Returns: The `String` value. 112 | func getString(name: String) throws -> String 113 | 114 | /// Retrieves an optional `String` value from the specified column name. 115 | /// - Parameter name: The name of the column. 116 | /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. 117 | /// - Returns: The `String` value if present, or `nil` if the value is null. 118 | func getStringOptional(name: String) throws -> String? 119 | 120 | /// The number of columns in the result set. 121 | var columnCount: Int { get } 122 | 123 | /// A dictionary mapping column names to their zero-based indices. 124 | var columnNames: [String: Int] { get } 125 | } 126 | 127 | /// An error type representing issues encountered while working with a `SqlCursor`. 128 | public enum SqlCursorError: Error { 129 | /// An expected column was not found. 130 | case columnNotFound(_ name: String) 131 | 132 | /// A column contained a null value when a non-null was expected. 133 | case nullValueFound(_ name: String) 134 | 135 | /// In some cases we have to serialize an error to a single string. This deserializes potential error strings. 136 | static func fromDescription(_ description: String) -> SqlCursorError? { 137 | // Example: "SqlCursorError:columnNotFound:user_id" 138 | let parts = description.split(separator: ":") 139 | 140 | // Ensure that the string follows the expected format 141 | guard parts.count == 3 else { return nil } 142 | 143 | let type = parts[1] // "columnNotFound" or "nullValueFound" 144 | let name = String(parts[2]) // The column name (e.g., "user_id") 145 | 146 | switch type { 147 | case "columnNotFound": 148 | return .columnNotFound(name) 149 | case "nullValueFound": 150 | return .nullValueFound(name) 151 | default: 152 | return nil 153 | } 154 | } 155 | } 156 | 157 | extension SqlCursorError: LocalizedError { 158 | public var errorDescription: String? { 159 | switch self { 160 | case .columnNotFound(let name): 161 | return "SqlCursorError:columnNotFound:\(name)" 162 | case .nullValueFound(let name): 163 | return "SqlCursorError:nullValueFound:\(name)" 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/db/Transaction.swift: -------------------------------------------------------------------------------- 1 | /// Represents a database transaction, inheriting the behavior of a connection context. 2 | /// This protocol can be used to define operations that should be executed within the scope of a transaction. 3 | public protocol Transaction: ConnectionContext {} 4 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/sync/BucketPriority.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents the priority of a bucket, used for sorting and managing operations based on priority levels. 4 | public struct BucketPriority: Comparable { 5 | /// The priority code associated with the bucket. Higher values indicate lower priority. 6 | public let priorityCode: Int32 7 | 8 | /// Initializes a new `BucketPriority` with the given priority code. 9 | /// - Parameter priorityCode: The priority code. Must be greater than or equal to 0. 10 | /// - Precondition: `priorityCode` must be >= 0. 11 | public init(_ priorityCode: Int32) { 12 | precondition(priorityCode >= 0, "priorityCode must be >= 0") 13 | self.priorityCode = priorityCode 14 | } 15 | 16 | /// Compares two `BucketPriority` instances to determine their order. 17 | /// - Parameters: 18 | /// - lhs: The left-hand side `BucketPriority` instance. 19 | /// - rhs: The right-hand side `BucketPriority` instance. 20 | /// - Returns: `true` if the left-hand side has a higher priority (lower `priorityCode`) than the right-hand side. 21 | /// - Note: Sorting is reversed, where a higher `priorityCode` means a lower priority. 22 | public static func < (lhs: BucketPriority, rhs: BucketPriority) -> Bool { 23 | return rhs.priorityCode < lhs.priorityCode 24 | } 25 | 26 | /// Represents the priority for a full synchronization operation, which has the lowest priority. 27 | public static let fullSyncPriority = BucketPriority(Int32.max) 28 | 29 | /// Represents the default priority for general operations. 30 | public static let defaultPriority = BucketPriority(3) 31 | } 32 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/sync/DownloadProgress.swift: -------------------------------------------------------------------------------- 1 | /// Information about a progressing download. 2 | /// 3 | /// This reports the ``totalOperations`` amount of operations to download, how many of them 4 | /// have already been downloaded as ``downloadedOperations`` and finally a ``fraction`` indicating 5 | /// relative progress. 6 | /// 7 | /// To obtain a ``ProgressWithOperations`` instance, either use ``SyncStatusData/downloadProgress`` 8 | /// for global progress or ``SyncDownloadProgress/untilPriority(priority:)``. 9 | public protocol ProgressWithOperations { 10 | /// How many operations need to be downloaded in total for the current download 11 | /// to complete. 12 | var totalOperations: Int32 { get } 13 | 14 | /// How many operations, out of ``totalOperations``, have already been downloaded. 15 | var downloadedOperations: Int32 { get } 16 | } 17 | 18 | public extension ProgressWithOperations { 19 | /// The relative amount of ``totalOperations`` to items in ``downloadedOperations``, as a 20 | /// number between `0.0` and `1.0` (inclusive). 21 | /// 22 | /// When this number reaches `1.0`, all changes have been received from the sync service. 23 | /// Actually applying these changes happens before the ``SyncStatusData/downloadProgress`` 24 | /// field is cleared though, so progress can stay at `1.0` for a short while before completing. 25 | var fraction: Float { 26 | if (self.totalOperations == 0) { 27 | return 0.0 28 | } 29 | 30 | return Float.init(self.downloadedOperations) / Float.init(self.totalOperations) 31 | } 32 | } 33 | 34 | /// Provides realtime progress on how PowerSync is downloading rows. 35 | /// 36 | /// This type reports progress by extending ``ProgressWithOperations``, meaning that the 37 | /// ``ProgressWithOperations/totalOperations``, ``ProgressWithOperations/downloadedOperations`` 38 | /// and ``ProgressWithOperations/fraction`` properties are available on this instance. 39 | /// Additionally, it's possible to obtain progress towards a specific priority only (instead 40 | /// of tracking progress for the entire download) by using ``untilPriority(priority:)``. 41 | /// 42 | /// The reported progress always reflects the status towards the end of a sync iteration (after 43 | /// which a consistent snapshot of all buckets is available locally). 44 | /// 45 | /// In rare cases (in particular, when a [compacting](https://docs.powersync.com/usage/lifecycle-maintenance/compacting-buckets) 46 | /// operation takes place between syncs), it's possible for the returned numbers to be slightly 47 | /// inaccurate. For this reason, ``SyncDownloadProgress`` should be seen as an approximation of progress. 48 | /// The information returned is good enough to build progress bars, but not exaxt enough to track 49 | /// individual download counts. 50 | /// 51 | /// Also note that data is downloaded in bulk, which means that individual counters are unlikely 52 | /// to be updated one-by-one. 53 | public protocol SyncDownloadProgress: ProgressWithOperations { 54 | /// Returns download progress towardss all data up until the specified `priority` 55 | /// being received. 56 | /// 57 | /// The returned ``ProgressWithOperations`` instance tracks the target amount of operations that 58 | /// need to be downloaded in total and how many of them have already been received. 59 | func untilPriority(priority: BucketPriority) -> ProgressWithOperations 60 | } 61 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/sync/PriorityStatusEntry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents the status of a bucket priority, including synchronization details. 4 | public struct PriorityStatusEntry { 5 | /// The priority of the bucket. 6 | public let priority: BucketPriority 7 | 8 | /// The date and time when the bucket was last synchronized. 9 | /// - Note: This value is optional and may be `nil` if the bucket has not been synchronized yet. 10 | public let lastSyncedAt: Date? 11 | 12 | /// Indicates whether the bucket has been successfully synchronized. 13 | /// - Note: This value is optional and may be `nil` if the synchronization status is unknown. 14 | public let hasSynced: Bool? 15 | } 16 | -------------------------------------------------------------------------------- /Sources/PowerSync/Protocol/sync/SyncStatusData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol representing the synchronization status of a system, providing various indicators and error states. 4 | public protocol SyncStatusData { 5 | /// Indicates whether the system is currently connected. 6 | var connected: Bool { get } 7 | 8 | /// Indicates whether the system is in the process of connecting. 9 | var connecting: Bool { get } 10 | 11 | /// Indicates whether the system is actively downloading changes. 12 | var downloading: Bool { get } 13 | 14 | /// Realtime progress information about downloaded operations during an active sync. 15 | /// 16 | /// For more information on what progress is reported, see ``SyncDownloadProgress``. 17 | /// This value will be non-null only if ``downloading`` is `true`. 18 | var downloadProgress: SyncDownloadProgress? { get } 19 | 20 | /// Indicates whether the system is actively uploading changes. 21 | var uploading: Bool { get } 22 | 23 | /// The date and time when the last synchronization was fully completed, if any. 24 | var lastSyncedAt: Date? { get } 25 | 26 | /// Indicates whether there has been at least one full synchronization. 27 | /// - Note: This value is `nil` when the state is unknown, for example, when the state is still being loaded. 28 | var hasSynced: Bool? { get } 29 | 30 | /// Represents any error that occurred during uploading. 31 | /// - Note: This value is cleared on the next successful upload. 32 | var uploadError: Any? { get } 33 | 34 | /// Represents any error that occurred during downloading (including connecting). 35 | /// - Note: This value is cleared on the next successful data download. 36 | var downloadError: Any? { get } 37 | 38 | /// A convenience property that returns either the `downloadError` or `uploadError`, if any. 39 | var anyError: Any? { get } 40 | 41 | /// A list of `PriorityStatusEntry` objects reporting the synchronization status for buckets within priorities. 42 | /// - Note: When buckets with different priorities are defined, this may contain entries before `hasSynced` 43 | /// and `lastSyncedAt` are set, indicating that a partial (but not complete) sync has completed. 44 | var priorityStatusEntries: [PriorityStatusEntry] { get } 45 | 46 | /// Retrieves the synchronization status for a specific priority. 47 | /// - Parameter priority: The priority for which the status is requested. 48 | /// - Returns: A `PriorityStatusEntry` representing the synchronization status for the given priority. 49 | func statusForPriority(_ priority: BucketPriority) -> PriorityStatusEntry 50 | } 51 | 52 | /// A protocol extending `SyncStatusData` to include flow-based updates for synchronization status. 53 | public protocol SyncStatus: SyncStatusData { 54 | /// Provides a flow of synchronization status updates. 55 | /// - Returns: An `AsyncStream` that emits updates whenever the synchronization status changes. 56 | func asFlow() -> AsyncStream 57 | } 58 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/Attachment.swift: -------------------------------------------------------------------------------- 1 | /// Enum representing the state of an attachment 2 | public enum AttachmentState: Int { 3 | /// The attachment has been queued for download from the cloud storage 4 | case queuedDownload 5 | /// The attachment has been queued for upload to the cloud storage 6 | case queuedUpload 7 | /// The attachment has been queued for delete in the cloud storage (and locally) 8 | case queuedDelete 9 | /// The attachment has been synced 10 | case synced 11 | /// The attachment has been orphaned, i.e., the associated record has been deleted 12 | case archived 13 | 14 | enum AttachmentStateError: Error { 15 | case invalidState(Int) 16 | } 17 | 18 | static func from(_ rawValue: Int) throws -> AttachmentState { 19 | guard let state = AttachmentState(rawValue: rawValue) else { 20 | throw AttachmentStateError.invalidState(rawValue) 21 | } 22 | return state 23 | } 24 | } 25 | 26 | /// Struct representing an attachment 27 | public struct Attachment { 28 | /// Unique identifier for the attachment 29 | public let id: String 30 | 31 | /// Timestamp for the last record update 32 | public let timestamp: Int 33 | 34 | /// Attachment filename, e.g. `[id].jpg` 35 | public let filename: String 36 | 37 | /// Current attachment state 38 | public let state: AttachmentState 39 | 40 | /// Local URI pointing to the attachment file 41 | public let localUri: String? 42 | 43 | /// Attachment media type (usually a MIME type) 44 | public let mediaType: String? 45 | 46 | /// Attachment byte size 47 | public let size: Int64? 48 | 49 | /// Specifies if the attachment has been synced locally before. 50 | /// This is particularly useful for restoring archived attachments in edge cases. 51 | public let hasSynced: Bool? 52 | 53 | /// Extra attachment metadata 54 | public let metaData: String? 55 | 56 | /// Initializes a new `Attachment` instance 57 | public init( 58 | id: String, 59 | filename: String, 60 | state: AttachmentState, 61 | timestamp: Int = 0, 62 | hasSynced: Bool? = false, 63 | localUri: String? = nil, 64 | mediaType: String? = nil, 65 | size: Int64? = nil, 66 | metaData: String? = nil 67 | ) { 68 | self.id = id 69 | self.timestamp = timestamp 70 | self.filename = filename 71 | self.state = state 72 | self.localUri = localUri 73 | self.mediaType = mediaType 74 | self.size = size 75 | self.hasSynced = hasSynced 76 | self.metaData = metaData 77 | } 78 | 79 | /// Returns a new `Attachment` instance with the option to override specific fields. 80 | /// 81 | /// - Parameters: 82 | /// - filename: Optional new filename. 83 | /// - state: Optional new state. 84 | /// - timestamp: Optional new timestamp. 85 | /// - hasSynced: Optional new `hasSynced` flag. 86 | /// - localUri: Optional new local URI. 87 | /// - mediaType: Optional new media type. 88 | /// - size: Optional new size. 89 | /// - metaData: Optional new metadata. 90 | /// - Returns: A new `Attachment` with updated values. 91 | func with( 92 | filename: String? = nil, 93 | state: AttachmentState? = nil, 94 | timestamp : Int = 0, 95 | hasSynced: Bool? = nil, 96 | localUri: String?? = .none, 97 | mediaType: String?? = .none, 98 | size: Int64?? = .none, 99 | metaData: String?? = .none 100 | ) -> Attachment { 101 | return Attachment( 102 | id: id, 103 | filename: filename ?? self.filename, 104 | state: state ?? self.state, 105 | timestamp: timestamp > 0 ? timestamp : self.timestamp, 106 | hasSynced: hasSynced ?? self.hasSynced, 107 | localUri: resolveOverride(localUri, current: self.localUri), 108 | mediaType: resolveOverride(mediaType, current: self.mediaType), 109 | size: resolveOverride(size, current: self.size), 110 | metaData: resolveOverride(metaData, current: self.metaData) 111 | ) 112 | } 113 | 114 | /// Resolves double optionals 115 | /// if a non nil value is provided: the override will be used 116 | /// if .some(nil) is provided: The value will be set to nil 117 | /// // if nil is provided: the current value will be preserved 118 | private func resolveOverride(_ override: T??, current: T?) -> T? { 119 | if let value = override { 120 | return value // could be nil (explicit clear) or a value 121 | } else { 122 | return current // not provided, use current 123 | } 124 | } 125 | 126 | 127 | /// Constructs an `Attachment` from a `SqlCursor`. 128 | /// 129 | /// - Parameter cursor: The `SqlCursor` containing the attachment data. 130 | /// - Throws: If required fields are missing or of incorrect type. 131 | /// - Returns: A fully constructed `Attachment` instance. 132 | public static func fromCursor(_ cursor: SqlCursor) throws -> Attachment { 133 | return try Attachment( 134 | id: cursor.getString(name: "id"), 135 | filename: cursor.getString(name: "filename"), 136 | state: AttachmentState.from(cursor.getInt(name: "state")), 137 | timestamp: cursor.getInt(name: "timestamp"), 138 | hasSynced: cursor.getInt(name: "has_synced") > 0, 139 | localUri: cursor.getStringOptional(name: "local_uri"), 140 | mediaType: cursor.getStringOptional(name: "media_type"), 141 | size: cursor.getInt64Optional(name: "size"), 142 | metaData: cursor.getStringOptional(name: "meta_data") 143 | ) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/AttachmentContext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Context which performs actions on the attachment records 4 | open class AttachmentContext { 5 | private let db: any PowerSyncDatabaseProtocol 6 | private let tableName: String 7 | private let logger: any LoggerProtocol 8 | private let logTag = "AttachmentService" 9 | private let maxArchivedCount: Int64 10 | 11 | /// Table used for storing attachments in the attachment queue. 12 | private var table: String { 13 | return tableName 14 | } 15 | 16 | /// Initializes a new `AttachmentContext`. 17 | public init( 18 | db: PowerSyncDatabaseProtocol, 19 | tableName: String, 20 | logger: any LoggerProtocol, 21 | maxArchivedCount: Int64 22 | ) { 23 | self.db = db 24 | self.tableName = tableName 25 | self.logger = logger 26 | self.maxArchivedCount = maxArchivedCount 27 | } 28 | 29 | /// Deletes the attachment from the attachment queue. 30 | public func deleteAttachment(id: String) async throws { 31 | _ = try await db.execute( 32 | sql: "DELETE FROM \(table) WHERE id = ?", 33 | parameters: [id] 34 | ) 35 | } 36 | 37 | /// Sets the state of the attachment to ignored (archived). 38 | public func ignoreAttachment(id: String) async throws { 39 | _ = try await db.execute( 40 | sql: "UPDATE \(table) SET state = ? WHERE id = ?", 41 | parameters: [AttachmentState.archived.rawValue, id] 42 | ) 43 | } 44 | 45 | /// Gets the attachment from the attachment queue using an ID. 46 | public func getAttachment(id: String) async throws -> Attachment? { 47 | return try await db.getOptional( 48 | sql: "SELECT * FROM \(table) WHERE id = ?", 49 | parameters: [id] 50 | ) { cursor in 51 | try Attachment.fromCursor(cursor) 52 | } 53 | } 54 | 55 | /// Saves the attachment to the attachment queue. 56 | public func saveAttachment(attachment: Attachment) async throws -> Attachment { 57 | return try await db.writeTransaction { ctx in 58 | try self.upsertAttachment(attachment, context: ctx) 59 | } 60 | } 61 | 62 | /// Saves multiple attachments to the attachment queue. 63 | public func saveAttachments(attachments: [Attachment]) async throws { 64 | if attachments.isEmpty { 65 | return 66 | } 67 | 68 | try await db.writeTransaction { tx in 69 | for attachment in attachments { 70 | _ = try self.upsertAttachment(attachment, context: tx) 71 | } 72 | } 73 | } 74 | 75 | /// Gets all the IDs of attachments in the attachment queue. 76 | public func getAttachmentIds() async throws -> [String] { 77 | return try await db.getAll( 78 | sql: "SELECT id FROM \(table) WHERE id IS NOT NULL", 79 | parameters: [] 80 | ) { cursor in 81 | try cursor.getString(name: "id") 82 | } 83 | } 84 | 85 | /// Gets all attachments in the attachment queue. 86 | public func getAttachments() async throws -> [Attachment] { 87 | return try await db.getAll( 88 | sql: """ 89 | SELECT 90 | * 91 | FROM 92 | \(table) 93 | WHERE 94 | id IS NOT NULL 95 | ORDER BY 96 | timestamp ASC 97 | """, 98 | parameters: [] 99 | ) { cursor in 100 | try Attachment.fromCursor(cursor) 101 | } 102 | } 103 | 104 | /// Gets all active attachments that require an operation to be performed. 105 | public func getActiveAttachments() async throws -> [Attachment] { 106 | return try await db.getAll( 107 | sql: """ 108 | SELECT 109 | * 110 | FROM 111 | \(table) 112 | WHERE 113 | state = ? 114 | OR state = ? 115 | OR state = ? 116 | ORDER BY 117 | timestamp ASC 118 | """, 119 | parameters: [ 120 | AttachmentState.queuedUpload.rawValue, 121 | AttachmentState.queuedDownload.rawValue, 122 | AttachmentState.queuedDelete.rawValue, 123 | ] 124 | ) { cursor in 125 | try Attachment.fromCursor(cursor) 126 | } 127 | } 128 | 129 | /// Clears the attachment queue. 130 | /// 131 | /// - Note: Currently only used for testing purposes. 132 | public func clearQueue() async throws { 133 | _ = try await db.execute("DELETE FROM \(table)") 134 | } 135 | 136 | /// Deletes attachments that have been archived. 137 | /// 138 | /// - Parameter callback: A callback invoked with the list of archived attachments before deletion. 139 | /// - Returns: `true` if all items have been deleted, `false` if there may be more archived items remaining. 140 | public func deleteArchivedAttachments(callback: @escaping ([Attachment]) async throws -> Void) async throws -> Bool { 141 | let limit = 1000 142 | let attachments = try await db.getAll( 143 | sql: """ 144 | SELECT 145 | * 146 | FROM 147 | \(table) 148 | WHERE 149 | state = ? 150 | ORDER BY 151 | timestamp DESC 152 | LIMIT ? OFFSET ? 153 | """, 154 | parameters: [ 155 | AttachmentState.archived.rawValue, 156 | limit, 157 | maxArchivedCount, 158 | ] 159 | ) { cursor in 160 | try Attachment.fromCursor(cursor) 161 | } 162 | 163 | try await callback(attachments) 164 | 165 | let ids = try JSONEncoder().encode(attachments.map { $0.id }) 166 | let idsString = String(data: ids, encoding: .utf8)! 167 | 168 | _ = try await db.execute( 169 | sql: "DELETE FROM \(table) WHERE id IN (SELECT value FROM json_each(?));", 170 | parameters: [idsString] 171 | ) 172 | 173 | return attachments.count < limit 174 | } 175 | 176 | /// Upserts an attachment record synchronously using a database transaction context. 177 | /// 178 | /// - Parameters: 179 | /// - attachment: The attachment to upsert. 180 | /// - context: The database transaction context. 181 | /// - Returns: The original attachment. 182 | public func upsertAttachment( 183 | _ attachment: Attachment, 184 | context: ConnectionContext 185 | ) throws -> Attachment { 186 | let timestamp = Int(Date().timeIntervalSince1970 * 1000) 187 | let updatedRecord = Attachment( 188 | id: attachment.id, 189 | filename: attachment.filename, 190 | state: attachment.state, 191 | timestamp: timestamp, 192 | hasSynced: attachment.hasSynced, 193 | localUri: attachment.localUri, 194 | mediaType: attachment.mediaType, 195 | size: attachment.size 196 | ) 197 | 198 | try context.execute( 199 | sql: """ 200 | INSERT OR REPLACE INTO 201 | \(table) (id, timestamp, filename, local_uri, media_type, size, state, has_synced, meta_data) 202 | VALUES 203 | (?, ?, ?, ?, ?, ?, ?, ?, ?) 204 | """, 205 | parameters: [ 206 | updatedRecord.id, 207 | updatedRecord.timestamp, 208 | updatedRecord.filename, 209 | updatedRecord.localUri, 210 | updatedRecord.mediaType, 211 | updatedRecord.size, 212 | updatedRecord.state.rawValue, 213 | updatedRecord.hasSynced ?? 0, 214 | updatedRecord.metaData 215 | ] 216 | ) 217 | 218 | return attachment 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/AttachmentService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Service which manages attachment records. 4 | open class AttachmentService { 5 | private let db: any PowerSyncDatabaseProtocol 6 | private let tableName: String 7 | private let logger: any LoggerProtocol 8 | private let logTag = "AttachmentService" 9 | 10 | private let context: AttachmentContext 11 | private let lock: LockActor 12 | 13 | /// Initializes the attachment service with the specified database, table name, logger, and max archived count. 14 | public init( 15 | db: PowerSyncDatabaseProtocol, 16 | tableName: String, 17 | logger: any LoggerProtocol, 18 | maxArchivedCount: Int64 19 | ) { 20 | self.db = db 21 | self.tableName = tableName 22 | self.logger = logger 23 | context = AttachmentContext( 24 | db: db, 25 | tableName: tableName, 26 | logger: logger, 27 | maxArchivedCount: maxArchivedCount 28 | ) 29 | lock = LockActor() 30 | } 31 | 32 | /// Watches for changes to the attachments table. 33 | public func watchActiveAttachments() throws -> AsyncThrowingStream<[String], Error> { 34 | logger.info("Watching attachments...", tag: logTag) 35 | 36 | return try db.watch( 37 | sql: """ 38 | SELECT 39 | id 40 | FROM 41 | \(tableName) 42 | WHERE 43 | state = ? 44 | OR state = ? 45 | OR state = ? 46 | ORDER BY 47 | timestamp ASC 48 | """, 49 | parameters: [ 50 | AttachmentState.queuedUpload.rawValue, 51 | AttachmentState.queuedDownload.rawValue, 52 | AttachmentState.queuedDelete.rawValue, 53 | ] 54 | ) { cursor in 55 | try cursor.getString(name: "id") 56 | } 57 | } 58 | 59 | /// Executes a callback with exclusive access to the attachment context. 60 | public func withContext(callback: @Sendable @escaping (AttachmentContext) async throws -> R) async throws -> R { 61 | try await lock.withLock { 62 | try await callback(context) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/AttachmentTable.swift: -------------------------------------------------------------------------------- 1 | /// Creates a PowerSync Schema table for attachment state 2 | public func createAttachmentTable(name: String) -> Table { 3 | return Table( 4 | name: name, 5 | columns: [ 6 | .integer("timestamp"), 7 | .integer("state"), 8 | .text("filename"), 9 | .integer("has_synced"), 10 | .text("local_uri"), 11 | .text("media_type"), 12 | .integer("size"), 13 | .text("meta_data"), 14 | ], 15 | localOnly: true 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/FileManagerLocalStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | * Implementation of LocalStorageAdapter using FileManager 5 | */ 6 | public class FileManagerStorageAdapter: LocalStorageAdapter { 7 | private let fileManager = FileManager.default 8 | 9 | public init() {} 10 | 11 | public func saveFile(filePath: String, data: Data) async throws -> Int64 { 12 | return try await Task { 13 | let url = URL(fileURLWithPath: filePath) 14 | 15 | // Make sure the parent directory exists 16 | try fileManager.createDirectory(at: url.deletingLastPathComponent(), 17 | withIntermediateDirectories: true) 18 | 19 | // Write data to file 20 | try data.write(to: url) 21 | 22 | // Return the size of the data 23 | return Int64(data.count) 24 | }.value 25 | } 26 | 27 | public func readFile(filePath: String, mediaType _: String?) async throws -> Data { 28 | return try await Task { 29 | let url = URL(fileURLWithPath: filePath) 30 | 31 | if !fileManager.fileExists(atPath: filePath) { 32 | throw PowerSyncAttachmentError.fileNotFound(filePath) 33 | } 34 | 35 | // Read data from file 36 | do { 37 | return try Data(contentsOf: url) 38 | } catch { 39 | throw PowerSyncAttachmentError.ioError(error) 40 | } 41 | }.value 42 | } 43 | 44 | public func deleteFile(filePath: String) async throws { 45 | try await Task { 46 | if fileManager.fileExists(atPath: filePath) { 47 | try fileManager.removeItem(atPath: filePath) 48 | } 49 | }.value 50 | } 51 | 52 | public func fileExists(filePath: String) async throws -> Bool { 53 | return await Task { 54 | fileManager.fileExists(atPath: filePath) 55 | }.value 56 | } 57 | 58 | public func makeDir(path: String) async throws { 59 | try await Task { 60 | try fileManager.createDirectory(atPath: path, 61 | withIntermediateDirectories: true, 62 | attributes: nil) 63 | }.value 64 | } 65 | 66 | public func rmDir(path: String) async throws { 67 | try await Task { 68 | if fileManager.fileExists(atPath: path) { 69 | try fileManager.removeItem(atPath: path) 70 | } 71 | }.value 72 | } 73 | 74 | public func copyFile(sourcePath: String, targetPath: String) async throws { 75 | try await Task { 76 | if !fileManager.fileExists(atPath: sourcePath) { 77 | throw PowerSyncAttachmentError.fileNotFound(sourcePath) 78 | } 79 | 80 | // Ensure target directory exists 81 | let targetUrl = URL(fileURLWithPath: targetPath) 82 | try fileManager.createDirectory(at: targetUrl.deletingLastPathComponent(), 83 | withIntermediateDirectories: true) 84 | 85 | // If target already exists, remove it first 86 | if fileManager.fileExists(atPath: targetPath) { 87 | try fileManager.removeItem(atPath: targetPath) 88 | } 89 | 90 | try fileManager.copyItem(atPath: sourcePath, toPath: targetPath) 91 | }.value 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/LocalStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Error type for PowerSync operations 4 | public enum PowerSyncAttachmentError: Error { 5 | /// A general error with an associated message 6 | case generalError(String) 7 | 8 | /// Indicates no matching attachment record could be found 9 | case notFound(String) 10 | 11 | /// Indicates that a file was not found at the given path 12 | case fileNotFound(String) 13 | 14 | /// An I/O error occurred 15 | case ioError(Error) 16 | 17 | /// The given file or directory path was invalid 18 | case invalidPath(String) 19 | 20 | /// The attachments queue or sub services have been closed 21 | case closed(String) 22 | } 23 | 24 | /// Protocol defining an adapter interface for local file storage 25 | public protocol LocalStorageAdapter { 26 | /// Saves data to a file at the specified path. 27 | /// 28 | /// - Parameters: 29 | /// - filePath: The full path where the file should be saved. 30 | /// - data: The binary data to save. 31 | /// - Returns: The byte size of the saved file. 32 | /// - Throws: `PowerSyncAttachmentError` if saving fails. 33 | func saveFile( 34 | filePath: String, 35 | data: Data 36 | ) async throws -> Int64 37 | 38 | /// Reads a file from the specified path. 39 | /// 40 | /// - Parameters: 41 | /// - filePath: The full path to the file. 42 | /// - mediaType: An optional media type (MIME type) to help determine how to handle the file. 43 | /// - Returns: The contents of the file as `Data`. 44 | /// - Throws: `PowerSyncAttachmentError` if reading fails or the file doesn't exist. 45 | func readFile( 46 | filePath: String, 47 | mediaType: String? 48 | ) async throws -> Data 49 | 50 | /// Deletes a file at the specified path. 51 | /// 52 | /// - Parameter filePath: The full path to the file to delete. 53 | /// - Throws: `PowerSyncAttachmentError` if deletion fails or file doesn't exist. 54 | func deleteFile(filePath: String) async throws 55 | 56 | /// Checks if a file exists at the specified path. 57 | /// 58 | /// - Parameter filePath: The path to the file. 59 | /// - Returns: `true` if the file exists, `false` otherwise. 60 | /// - Throws: `PowerSyncAttachmentError` if checking fails. 61 | func fileExists(filePath: String) async throws -> Bool 62 | 63 | /// Creates a directory at the specified path. 64 | /// 65 | /// - Parameter path: The full path to the directory. 66 | /// - Throws: `PowerSyncAttachmentError` if creation fails. 67 | func makeDir(path: String) async throws 68 | 69 | /// Removes a directory at the specified path. 70 | /// 71 | /// - Parameter path: The full path to the directory. 72 | /// - Throws: `PowerSyncAttachmentError` if removal fails. 73 | func rmDir(path: String) async throws 74 | 75 | /// Copies a file from the source path to the target path. 76 | /// 77 | /// - Parameters: 78 | /// - sourcePath: The original file path. 79 | /// - targetPath: The destination file path. 80 | /// - Throws: `PowerSyncAttachmentError` if the copy operation fails. 81 | func copyFile( 82 | sourcePath: String, 83 | targetPath: String 84 | ) async throws 85 | } 86 | 87 | /// Extension providing a default implementation of `readFile` without a media type 88 | public extension LocalStorageAdapter { 89 | /// Reads a file from the specified path without specifying a media type. 90 | /// 91 | /// - Parameter filePath: The full path to the file. 92 | /// - Returns: The contents of the file as `Data`. 93 | /// - Throws: `PowerSyncAttachmentError` if reading fails. 94 | func readFile(filePath: String) async throws -> Data { 95 | return try await readFile(filePath: filePath, mediaType: nil) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/LockActor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | actor LockActor { 4 | private var isLocked = false 5 | private var waiters: [(id: UUID, continuation: CheckedContinuation)] = [] 6 | 7 | func withLock(_ operation: @Sendable () async throws -> T) async throws -> T { 8 | try await waitUntilUnlocked() 9 | 10 | isLocked = true 11 | defer { unlockNext() } 12 | 13 | try Task.checkCancellation() // cancellation check after acquiring lock 14 | return try await operation() 15 | } 16 | 17 | private func waitUntilUnlocked() async throws { 18 | if !isLocked { return } 19 | 20 | let id = UUID() 21 | 22 | // Use withTaskCancellationHandler to manage cancellation 23 | await withTaskCancellationHandler { 24 | await withCheckedContinuation { continuation in 25 | waiters.append((id: id, continuation: continuation)) 26 | } 27 | } onCancel: { 28 | // Cancellation logic: remove the waiter when cancelled 29 | Task { 30 | await self.removeWaiter(id: id) 31 | } 32 | } 33 | } 34 | 35 | private func removeWaiter(id: UUID) async { 36 | // Safely remove the waiter from the actor's waiters list 37 | waiters.removeAll { $0.id == id } 38 | } 39 | 40 | private func unlockNext() { 41 | if let next = waiters.first { 42 | waiters.removeFirst() 43 | next.continuation.resume() 44 | } else { 45 | isLocked = false 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/RemoteStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Adapter for interfacing with remote attachment storage. 4 | public protocol RemoteStorageAdapter { 5 | /// Uploads a file to remote storage. 6 | /// 7 | /// - Parameters: 8 | /// - fileData: The binary content of the file to upload. 9 | /// - attachment: The associated `Attachment` metadata describing the file. 10 | /// - Throws: An error if the upload fails. 11 | func uploadFile( 12 | fileData: Data, 13 | attachment: Attachment 14 | ) async throws 15 | 16 | /// Downloads a file from remote storage. 17 | /// 18 | /// - Parameter attachment: The `Attachment` describing the file to download. 19 | /// - Returns: The binary data of the downloaded file. 20 | /// - Throws: An error if the download fails or the file is not found. 21 | func downloadFile(attachment: Attachment) async throws -> Data 22 | 23 | /// Deletes a file from remote storage. 24 | /// 25 | /// - Parameter attachment: The `Attachment` describing the file to delete. 26 | /// - Throws: An error if the deletion fails or the file does not exist. 27 | func deleteFile(attachment: Attachment) async throws 28 | } 29 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/SyncErrorHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Handles attachment operation errors. 4 | /// 5 | /// The handlers defined in this protocol specify whether corresponding attachment 6 | /// operations (download, upload, delete) should be retried upon failure. 7 | /// 8 | /// If an operation fails and should not be retried, the attachment record is archived. 9 | public protocol SyncErrorHandler { 10 | /// Handles a download error for a specific attachment. 11 | /// 12 | /// - Parameters: 13 | /// - attachment: The `Attachment` that failed to download. 14 | /// - error: The error encountered during the download operation. 15 | /// - Returns: `true` if the operation should be retried, `false` if it should be archived. 16 | func onDownloadError( 17 | attachment: Attachment, 18 | error: Error 19 | ) async -> Bool 20 | 21 | /// Handles an upload error for a specific attachment. 22 | /// 23 | /// - Parameters: 24 | /// - attachment: The `Attachment` that failed to upload. 25 | /// - error: The error encountered during the upload operation. 26 | /// - Returns: `true` if the operation should be retried, `false` if it should be archived. 27 | func onUploadError( 28 | attachment: Attachment, 29 | error: Error 30 | ) async -> Bool 31 | 32 | /// Handles a delete error for a specific attachment. 33 | /// 34 | /// - Parameters: 35 | /// - attachment: The `Attachment` that failed to be deleted. 36 | /// - error: The error encountered during the delete operation. 37 | /// - Returns: `true` if the operation should be retried, `false` if it should be archived. 38 | func onDeleteError( 39 | attachment: Attachment, 40 | error: Error 41 | ) async -> Bool 42 | } 43 | 44 | /// Default implementation of `SyncErrorHandler`. 45 | /// 46 | /// By default, all operations return `false`, indicating no retry. 47 | public class DefaultSyncErrorHandler: SyncErrorHandler { 48 | public init() {} 49 | 50 | public func onDownloadError(attachment _: Attachment, error _: Error) async -> Bool { 51 | // Default: do not retry failed downloads 52 | return false 53 | } 54 | 55 | public func onUploadError(attachment _: Attachment, error _: Error) async -> Bool { 56 | // Default: do not retry failed uploads 57 | return false 58 | } 59 | 60 | public func onDeleteError(attachment _: Attachment, error _: Error) async -> Bool { 61 | // Default: do not retry failed deletions 62 | return false 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/PowerSync/attachments/WatchedAttachmentItem.swift: -------------------------------------------------------------------------------- 1 | 2 | import Combine 3 | import Foundation 4 | 5 | /// A watched attachment record item. 6 | /// This is usually returned from watching all relevant attachment IDs. 7 | public struct WatchedAttachmentItem { 8 | /// Id for the attachment record 9 | public let id: String 10 | 11 | /// File extension used to determine an internal filename for storage if no `filename` is provided 12 | public let fileExtension: String? 13 | 14 | /// Filename to store the attachment with 15 | public let filename: String? 16 | 17 | /// Metadata for the attachment (optional) 18 | public let metaData: String? 19 | 20 | /// Initializes a new `WatchedAttachmentItem` 21 | /// - Parameters: 22 | /// - id: Attachment record ID 23 | /// - fileExtension: Optional file extension 24 | /// - filename: Optional filename 25 | /// - metaData: Optional metadata 26 | public init( 27 | id: String, 28 | fileExtension: String? = nil, 29 | filename: String? = nil, 30 | metaData: String? = nil 31 | ) { 32 | self.id = id 33 | self.fileExtension = fileExtension 34 | self.filename = filename 35 | self.metaData = metaData 36 | 37 | precondition(fileExtension != nil || filename != nil, "Either fileExtension or filename must be provided.") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/AttachmentTests.swift: -------------------------------------------------------------------------------- 1 | 2 | @testable import PowerSync 3 | import XCTest 4 | 5 | final class AttachmentTests: XCTestCase { 6 | private var database: PowerSyncDatabaseProtocol! 7 | private var schema: Schema! 8 | 9 | override func setUp() async throws { 10 | try await super.setUp() 11 | schema = Schema(tables: [ 12 | Table(name: "users", columns: [ 13 | .text("name"), 14 | .text("email"), 15 | .text("photo_id") 16 | ]), 17 | createAttachmentTable(name: "attachments") 18 | ]) 19 | 20 | database = PowerSyncDatabase( 21 | schema: schema, 22 | dbFilename: ":memory:" 23 | ) 24 | try await database.disconnectAndClear() 25 | } 26 | 27 | override func tearDown() async throws { 28 | try await database.disconnectAndClear() 29 | database = nil 30 | try await super.tearDown() 31 | } 32 | 33 | func getAttachmentDirectory() -> String { 34 | URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("attachments").path 35 | } 36 | 37 | func testAttachmentDownload() async throws { 38 | let queue = AttachmentQueue( 39 | db: database, 40 | remoteStorage: { 41 | struct MockRemoteStorage: RemoteStorageAdapter { 42 | func uploadFile( 43 | fileData: Data, 44 | attachment: Attachment 45 | ) async throws {} 46 | 47 | /** 48 | * Download a file from remote storage 49 | */ 50 | func downloadFile(attachment: Attachment) async throws -> Data { 51 | return Data([1, 2, 3]) 52 | } 53 | 54 | /** 55 | * Delete a file from remote storage 56 | */ 57 | func deleteFile(attachment: Attachment) async throws {} 58 | } 59 | 60 | return MockRemoteStorage() 61 | }(), 62 | attachmentsDirectory: getAttachmentDirectory(), 63 | watchAttachments: { try self.database.watch(options: WatchOptions( 64 | sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", 65 | mapper: { cursor in try WatchedAttachmentItem( 66 | id: cursor.getString(name: "photo_id"), 67 | fileExtension: "jpg" 68 | ) } 69 | )) } 70 | ) 71 | 72 | try await queue.startSync() 73 | 74 | // Create a user which has a photo_id associated. 75 | // This will be treated as a download since no attachment record was created. 76 | // saveFile creates the attachment record before the updates are made. 77 | _ = try await database.execute( 78 | sql: "INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), 'steven', 'steven@example.com', uuid())", 79 | parameters: [] 80 | ) 81 | 82 | let attachmentsWatch = try database.watch( 83 | options: WatchOptions( 84 | sql: "SELECT * FROM attachments", 85 | mapper: { cursor in try Attachment.fromCursor(cursor) } 86 | )).makeAsyncIterator() 87 | 88 | let attachmentRecord = try await waitForMatch( 89 | iterator: attachmentsWatch, 90 | where: { results in results.first?.state == AttachmentState.synced }, 91 | timeout: 5 92 | ).first 93 | 94 | // The file should exist 95 | let localData = try await queue.localStorage.readFile(filePath: attachmentRecord!.localUri!) 96 | XCTAssertEqual(localData.count, 3) 97 | 98 | try await queue.clearQueue() 99 | try await queue.close() 100 | } 101 | 102 | func testAttachmentUpload() async throws { 103 | class MockRemoteStorage: RemoteStorageAdapter { 104 | public var uploadCalled = false 105 | 106 | func uploadFile( 107 | fileData: Data, 108 | attachment: Attachment 109 | ) async throws { 110 | uploadCalled = true 111 | } 112 | 113 | /** 114 | * Download a file from remote storage 115 | */ 116 | func downloadFile(attachment: Attachment) async throws -> Data { 117 | return Data([1, 2, 3]) 118 | } 119 | 120 | /** 121 | * Delete a file from remote storage 122 | */ 123 | func deleteFile(attachment: Attachment) async throws {} 124 | } 125 | 126 | let mockedRemote = MockRemoteStorage() 127 | 128 | let queue = AttachmentQueue( 129 | db: database, 130 | remoteStorage: mockedRemote, 131 | attachmentsDirectory: getAttachmentDirectory(), 132 | watchAttachments: { try self.database.watch(options: WatchOptions( 133 | sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", 134 | mapper: { cursor in try WatchedAttachmentItem( 135 | id: cursor.getString(name: "photo_id"), 136 | fileExtension: "jpg" 137 | ) } 138 | )) } 139 | ) 140 | 141 | try await queue.startSync() 142 | 143 | let attachmentsWatch = try database.watch( 144 | options: WatchOptions( 145 | sql: "SELECT * FROM attachments", 146 | mapper: { cursor in try Attachment.fromCursor(cursor) } 147 | )).makeAsyncIterator() 148 | 149 | _ = try await queue.saveFile( 150 | data: Data([3, 4, 5]), 151 | mediaType: "image/jpg", 152 | fileExtension: "jpg" 153 | ) { tx, attachment in 154 | _ = try tx.execute( 155 | sql: "INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), 'john', 'j@j.com', ?)", 156 | parameters: [attachment.id] 157 | ) 158 | } 159 | 160 | _ = try await waitForMatch( 161 | iterator: attachmentsWatch, 162 | where: { results in results.first?.state == AttachmentState.synced }, 163 | timeout: 5 164 | ).first 165 | 166 | // Upload should have been called 167 | XCTAssertTrue(mockedRemote.uploadCalled) 168 | 169 | try await queue.clearQueue() 170 | try await queue.close() 171 | } 172 | } 173 | 174 | public enum WaitForMatchError: Error { 175 | case timeout(lastError: Error? = nil) 176 | case predicateFail(message: String) 177 | } 178 | 179 | public func waitForMatch( 180 | iterator: AsyncThrowingStream.Iterator, 181 | where predicate: @escaping (T) -> Bool, 182 | timeout: TimeInterval 183 | ) async throws -> T { 184 | let timeoutNanoseconds = UInt64(timeout * 1_000_000_000) 185 | 186 | return try await withThrowingTaskGroup(of: T.self) { group in 187 | // Task to wait for a matching value 188 | group.addTask { 189 | var localIterator = iterator 190 | while let value = try await localIterator.next() { 191 | if predicate(value) { 192 | return value 193 | } 194 | } 195 | throw WaitForMatchError.timeout() // stream ended before match 196 | } 197 | 198 | // Task to enforce timeout 199 | group.addTask { 200 | try await Task.sleep(nanoseconds: timeoutNanoseconds) 201 | throw WaitForMatchError.timeout() 202 | } 203 | 204 | // First one to succeed or fail 205 | let result = try await group.next() 206 | group.cancelAll() 207 | return result! 208 | } 209 | } 210 | 211 | func waitFor( 212 | timeout: TimeInterval = 0.5, 213 | interval: TimeInterval = 0.1, 214 | predicate: () async throws -> Void 215 | ) async throws { 216 | let intervalNanoseconds = UInt64(interval * 1_000_000_000) 217 | 218 | let timeoutDate = Date( 219 | timeIntervalSinceNow: timeout 220 | ) 221 | 222 | var lastError: Error? 223 | 224 | while Date() < timeoutDate { 225 | do { 226 | try await predicate() 227 | return 228 | } catch { 229 | lastError = error 230 | } 231 | try await Task.sleep(nanoseconds: intervalNanoseconds) 232 | } 233 | 234 | throw WaitForMatchError.timeout( 235 | lastError: lastError 236 | ) 237 | } 238 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/ConnectTests.swift: -------------------------------------------------------------------------------- 1 | @testable import PowerSync 2 | import XCTest 3 | 4 | final class ConnectTests: XCTestCase { 5 | private var database: (any PowerSyncDatabaseProtocol)! 6 | private var schema: Schema! 7 | 8 | override func setUp() async throws { 9 | try await super.setUp() 10 | schema = Schema(tables: [ 11 | Table( 12 | name: "users", 13 | columns: [ 14 | .text("name"), 15 | .text("email"), 16 | .text("photo_id"), 17 | ] 18 | ), 19 | ]) 20 | 21 | database = KotlinPowerSyncDatabaseImpl( 22 | schema: schema, 23 | dbFilename: ":memory:", 24 | logger: DatabaseLogger(DefaultLogger()) 25 | ) 26 | try await database.disconnectAndClear() 27 | } 28 | 29 | override func tearDown() async throws { 30 | try await database.disconnectAndClear() 31 | try await database.close() 32 | database = nil 33 | try await super.tearDown() 34 | } 35 | 36 | /// Tests passing basic JSON as client parameters 37 | func testClientParameters() async throws { 38 | /// This is an example of specifying JSON client params. 39 | /// The test here just ensures that the Kotlin SDK accepts these params and does not crash 40 | try await database.connect( 41 | connector: PowerSyncBackendConnector(), 42 | params: [ 43 | "foo": .string("bar"), 44 | ] 45 | ) 46 | } 47 | 48 | func testSyncStatus() async throws { 49 | XCTAssert(database.currentStatus.connected == false) 50 | XCTAssert(database.currentStatus.connecting == false) 51 | 52 | try await database.connect( 53 | connector: PowerSyncBackendConnector() 54 | ) 55 | 56 | try await waitFor(timeout: 10) { 57 | guard database.currentStatus.connecting == true else { 58 | throw WaitForMatchError.predicateFail(message: "Should be connecting") 59 | } 60 | } 61 | 62 | try await database.disconnect() 63 | 64 | try await waitFor(timeout: 10) { 65 | guard database.currentStatus.connecting == false else { 66 | throw WaitForMatchError.predicateFail(message: "Should not be connecting after disconnect") 67 | } 68 | } 69 | } 70 | 71 | func testSyncStatusUpdates() async throws { 72 | let expectation = XCTestExpectation( 73 | description: "Watch Sync Status" 74 | ) 75 | 76 | let watchTask = Task { 77 | for try await _ in database.currentStatus.asFlow() { 78 | expectation.fulfill() 79 | } 80 | } 81 | 82 | // Do some connecting operations 83 | try await database.connect( 84 | connector: PowerSyncBackendConnector() 85 | ) 86 | 87 | // We should get an update 88 | await fulfillment(of: [expectation], timeout: 5) 89 | watchTask.cancel() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/CrudTests.swift: -------------------------------------------------------------------------------- 1 | @testable import PowerSync 2 | import XCTest 3 | 4 | final class CrudTests: XCTestCase { 5 | private var database: (any PowerSyncDatabaseProtocol)! 6 | private var schema: Schema! 7 | 8 | override func setUp() async throws { 9 | try await super.setUp() 10 | schema = Schema(tables: [ 11 | Table( 12 | name: "users", 13 | columns: [ 14 | .text("name"), 15 | .text("email"), 16 | .integer("favorite_number"), 17 | .text("photo_id"), 18 | ] 19 | ), 20 | ]) 21 | 22 | database = KotlinPowerSyncDatabaseImpl( 23 | schema: schema, 24 | dbFilename: ":memory:", 25 | logger: DatabaseLogger(DefaultLogger()) 26 | ) 27 | try await database.disconnectAndClear() 28 | } 29 | 30 | override func tearDown() async throws { 31 | try await database.disconnectAndClear() 32 | try await database.close() 33 | database = nil 34 | try await super.tearDown() 35 | } 36 | 37 | func testTrackMetadata() async throws { 38 | try await database.updateSchema(schema: Schema(tables: [ 39 | Table(name: "lists", columns: [.text("name")], trackMetadata: true) 40 | ])) 41 | 42 | try await database.execute("INSERT INTO lists (id, name, _metadata) VALUES (uuid(), 'test', 'so meta')") 43 | guard let batch = try await database.getNextCrudTransaction() else { 44 | return XCTFail("Should have batch after insert") 45 | } 46 | 47 | XCTAssertEqual(batch.crud[0].metadata, "so meta") 48 | } 49 | 50 | func testTrackPreviousValues() async throws { 51 | try await database.updateSchema(schema: Schema(tables: [ 52 | Table( 53 | name: "lists", 54 | columns: [.text("name"), .text("content")], 55 | trackPreviousValues: TrackPreviousValuesOptions() 56 | ) 57 | ])) 58 | 59 | try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") 60 | try await database.execute("DELETE FROM ps_crud") 61 | try await database.execute("UPDATE lists SET name = 'new name'") 62 | 63 | guard let batch = try await database.getNextCrudTransaction() else { 64 | return XCTFail("Should have batch after update") 65 | } 66 | 67 | XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry", "content": "content"]) 68 | } 69 | 70 | func testTrackPreviousValuesWithFilter() async throws { 71 | try await database.updateSchema(schema: Schema(tables: [ 72 | Table( 73 | name: "lists", 74 | columns: [.text("name"), .text("content")], 75 | trackPreviousValues: TrackPreviousValuesOptions( 76 | columnFilter: ["name"] 77 | ) 78 | ) 79 | ])) 80 | 81 | try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") 82 | try await database.execute("DELETE FROM ps_crud") 83 | try await database.execute("UPDATE lists SET name = 'new name'") 84 | 85 | guard let batch = try await database.getNextCrudTransaction() else { 86 | return XCTFail("Should have batch after update") 87 | } 88 | 89 | XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"]) 90 | } 91 | 92 | func testTrackPreviousValuesOnlyWhenChanged() async throws { 93 | try await database.updateSchema(schema: Schema(tables: [ 94 | Table( 95 | name: "lists", 96 | columns: [.text("name"), .text("content")], 97 | trackPreviousValues: TrackPreviousValuesOptions( 98 | onlyWhenChanged: true 99 | ) 100 | ) 101 | ])) 102 | 103 | try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") 104 | try await database.execute("DELETE FROM ps_crud") 105 | try await database.execute("UPDATE lists SET name = 'new name'") 106 | 107 | guard let batch = try await database.getNextCrudTransaction() else { 108 | return XCTFail("Should have batch after update") 109 | } 110 | 111 | XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"]) 112 | } 113 | 114 | func testIgnoreEmptyUpdate() async throws { 115 | try await database.updateSchema(schema: Schema(tables: [ 116 | Table(name: "lists", columns: [.text("name")], ignoreEmptyUpdates: true) 117 | ])) 118 | try await database.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'test')") 119 | try await database.execute("DELETE FROM ps_crud") 120 | try await database.execute("UPDATE lists SET name = 'test'") // Same value! 121 | 122 | let batch = try await database.getNextCrudTransaction() 123 | XCTAssertNil(batch) 124 | } 125 | 126 | func testCrudBatch() async throws { 127 | // Create some items 128 | try await database.writeTransaction { tx in 129 | for i in 0 ..< 100 { 130 | try tx.execute( 131 | sql: "INSERT INTO users (id, name, email, favorite_number) VALUES (uuid(), 'a', 'a@example.com', ?)", 132 | parameters: [i] 133 | ) 134 | } 135 | } 136 | 137 | // Get a limited set of batched operations 138 | guard let limitedBatch = try await database.getCrudBatch(limit: 50) else { 139 | return XCTFail("Failed to get crud batch") 140 | } 141 | 142 | guard let crudItem = limitedBatch.crud.first else { 143 | return XCTFail("Crud batch should contain crud entries") 144 | } 145 | 146 | // This should show as a string even though it's a number 147 | // This is what the typing conveys 148 | let opData = crudItem.opData?["favorite_number"] 149 | XCTAssert(opData == "0") 150 | 151 | XCTAssert(limitedBatch.hasMore == true) 152 | XCTAssert(limitedBatch.crud.count == 50) 153 | 154 | guard let fullBatch = try await database.getCrudBatch() else { 155 | return XCTFail("Failed to get crud batch") 156 | } 157 | 158 | XCTAssert(fullBatch.hasMore == false) 159 | XCTAssert(fullBatch.crud.count == 100) 160 | 161 | guard let nextTx = try await database.getNextCrudTransaction() else { 162 | return XCTFail("Failed to get transaction crud batch") 163 | } 164 | 165 | XCTAssert(nextTx.crud.count == 100) 166 | 167 | for r in nextTx.crud { 168 | print(r) 169 | } 170 | 171 | // Completing the transaction should clear the items 172 | try await nextTx.complete() 173 | 174 | let afterCompleteBatch = try await database.getNextCrudTransaction() 175 | 176 | for r in afterCompleteBatch?.crud ?? [] { 177 | print(r) 178 | } 179 | 180 | XCTAssertNil(afterCompleteBatch) 181 | 182 | try await database.writeTransaction { tx in 183 | for i in 0 ..< 100 { 184 | try tx.execute( 185 | sql: "INSERT INTO users (id, name, email, favorite_number) VALUES (uuid(), 'a', 'a@example.com', ?)", 186 | parameters: [i] 187 | ) 188 | } 189 | } 190 | 191 | guard let finalBatch = try await database.getCrudBatch(limit: 100) else { 192 | return XCTFail("Failed to get crud batch") 193 | } 194 | XCTAssert(finalBatch.crud.count == 100) 195 | XCTAssert(finalBatch.hasMore == false) 196 | // Calling complete without a writeCheckpoint param should be possible 197 | try await finalBatch.complete() 198 | 199 | let finalValidationBatch = try await database.getCrudBatch(limit: 100) 200 | XCTAssertNil(finalValidationBatch) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/Kotlin/TestLogger.swift: -------------------------------------------------------------------------------- 1 | @testable import PowerSync 2 | 3 | 4 | class TestLogWriterAdapter: LogWriterProtocol { 5 | var logs = [String]() 6 | 7 | func log(severity: LogSeverity, message: String, tag: String?) { 8 | logs.append("\(severity): \(message) \(tag != nil ? "\(tag!)" : "")") 9 | } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/Schema/ColumnTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PowerSync 3 | 4 | final class ColumnTests: XCTestCase { 5 | 6 | func testColumnInitialization() { 7 | let name = "testColumn" 8 | let type = ColumnData.text 9 | 10 | let column = Column(name: name, type: type) 11 | 12 | XCTAssertEqual(column.name, name) 13 | XCTAssertEqual(column.type, type) 14 | } 15 | 16 | func testTextColumnFactory() { 17 | let name = "textColumn" 18 | let column = Column.text(name) 19 | 20 | XCTAssertEqual(column.name, name) 21 | XCTAssertEqual(column.type, .text) 22 | } 23 | 24 | func testIntegerColumnFactory() { 25 | let name = "intColumn" 26 | let column = Column.integer(name) 27 | 28 | XCTAssertEqual(column.name, name) 29 | XCTAssertEqual(column.type, .integer) 30 | } 31 | 32 | func testRealColumnFactory() { 33 | let name = "realColumn" 34 | let column = Column.real(name) 35 | 36 | XCTAssertEqual(column.name, name) 37 | XCTAssertEqual(column.type, .real) 38 | } 39 | 40 | func testEmptyColumnName() { 41 | let column = Column(name: "", type: .text) 42 | XCTAssertEqual(column.name, "") 43 | } 44 | 45 | func testColumnDataTypeEquality() { 46 | XCTAssertEqual(ColumnData.text, ColumnData.text) 47 | XCTAssertEqual(ColumnData.integer, ColumnData.integer) 48 | XCTAssertEqual(ColumnData.real, ColumnData.real) 49 | 50 | XCTAssertNotEqual(ColumnData.text, ColumnData.integer) 51 | XCTAssertNotEqual(ColumnData.text, ColumnData.real) 52 | XCTAssertNotEqual(ColumnData.integer, ColumnData.real) 53 | } 54 | 55 | func testMultipleColumnCreation() { 56 | let columns = [ 57 | Column.text("name"), 58 | Column.integer("age"), 59 | Column.real("score") 60 | ] 61 | 62 | XCTAssertEqual(columns[0].type, .text) 63 | XCTAssertEqual(columns[1].type, .integer) 64 | XCTAssertEqual(columns[2].type, .real) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/Schema/IndexTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PowerSync 3 | 4 | final class IndexTests: XCTestCase { 5 | 6 | private func makeIndexedColumn(_ name: String) -> IndexedColumnProtocol { 7 | return IndexedColumn.ascending(name) 8 | } 9 | 10 | func testBasicInitialization() { 11 | let name = "test_index" 12 | let columns: [IndexedColumnProtocol] = [ 13 | makeIndexedColumn("column1"), 14 | makeIndexedColumn("column2") 15 | ] 16 | 17 | let index = Index(name: name, columns: columns) 18 | 19 | XCTAssertEqual(index.name, name) 20 | XCTAssertEqual(index.columns.count, 2) 21 | XCTAssertEqual((index.columns[0] as? IndexedColumn)?.column, "column1") 22 | XCTAssertEqual((index.columns[1] as? IndexedColumn)?.column, "column2") 23 | } 24 | 25 | func testVariadicInitialization() { 26 | let name = "test_index" 27 | let column1 = makeIndexedColumn("column1") 28 | let column2 = makeIndexedColumn("column2") 29 | 30 | let index = Index(name: name, column1, column2) 31 | 32 | XCTAssertEqual(index.name, name) 33 | XCTAssertEqual(index.columns.count, 2) 34 | XCTAssertEqual((index.columns[0]).column, "column1") 35 | XCTAssertEqual((index.columns[1]).column, "column2") 36 | } 37 | 38 | func testAscendingFactoryWithMultipleColumns() { 39 | let name = "test_index" 40 | let columnNames = ["column1", "column2", "column3"] 41 | 42 | let index = Index.ascending(name: name, columns: columnNames) 43 | 44 | XCTAssertEqual(index.name, name) 45 | XCTAssertEqual(index.columns.count, 3) 46 | 47 | // Verify each column is correctly created 48 | for (i, columnName) in columnNames.enumerated() { 49 | let indexedColumn = index.columns[i] 50 | XCTAssertEqual(indexedColumn.column, columnName) 51 | XCTAssertTrue(indexedColumn.ascending) 52 | } 53 | } 54 | 55 | func testAscendingFactoryWithSingleColumn() { 56 | let name = "test_index" 57 | let columnName = "column1" 58 | 59 | let index = Index.ascending(name: name, column: columnName) 60 | 61 | XCTAssertEqual(index.name, name) 62 | XCTAssertEqual(index.columns.count, 1) 63 | 64 | let indexedColumn = index.columns[0] 65 | XCTAssertEqual(indexedColumn.column, columnName) 66 | XCTAssertTrue(indexedColumn.ascending) 67 | } 68 | 69 | func testMixedColumnTypes() { 70 | let name = "mixed_index" 71 | let columns: [IndexedColumnProtocol] = [ 72 | IndexedColumn.ascending("column1"), 73 | IndexedColumn.descending("column2"), 74 | IndexedColumn.ascending("column3") 75 | ] 76 | 77 | let index = Index(name: name, columns: columns) 78 | 79 | XCTAssertEqual(index.name, name) 80 | XCTAssertEqual(index.columns.count, 3) 81 | 82 | let col1 = index.columns[0] 83 | let col2 = index.columns[1] 84 | let col3 = index.columns[2] 85 | 86 | XCTAssertEqual(col1.column, "column1") 87 | XCTAssertTrue(col1.ascending) 88 | 89 | XCTAssertEqual(col2.column, "column2") 90 | XCTAssertFalse(col2.ascending) 91 | 92 | XCTAssertEqual(col3.column, "column3") 93 | XCTAssertTrue(col3.ascending) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/Schema/IndexedColumnTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PowerSync 3 | 4 | final class IndexedColumnTests: XCTestCase { 5 | 6 | func testBasicInitialization() { 7 | let column = IndexedColumn(column: "test", ascending: true) 8 | 9 | XCTAssertEqual(column.column, "test") 10 | XCTAssertTrue(column.ascending) 11 | } 12 | 13 | func testDefaultAscendingValue() { 14 | let column = IndexedColumn(column: "test") 15 | XCTAssertTrue(column.ascending) 16 | } 17 | 18 | func testDescendingInitialization() { 19 | let column = IndexedColumn(column: "test", ascending: false) 20 | 21 | XCTAssertEqual(column.column, "test") 22 | XCTAssertFalse(column.ascending) 23 | } 24 | 25 | func testIgnoresOptionalParameters() { 26 | let column = IndexedColumn( 27 | column: "test", 28 | ascending: true 29 | ) 30 | 31 | XCTAssertEqual(column.column, "test") 32 | XCTAssertTrue(column.ascending) 33 | } 34 | 35 | func testAscendingFactory() { 36 | let column = IndexedColumn.ascending("test") 37 | 38 | XCTAssertEqual(column.column, "test") 39 | XCTAssertTrue(column.ascending) 40 | } 41 | 42 | func testDescendingFactory() { 43 | let column = IndexedColumn.descending("test") 44 | 45 | XCTAssertEqual(column.column, "test") 46 | XCTAssertFalse(column.ascending) 47 | } 48 | 49 | func testMultipleInstances() { 50 | let columns = [ 51 | IndexedColumn.ascending("first"), 52 | IndexedColumn.descending("second"), 53 | IndexedColumn(column: "third") 54 | ] 55 | 56 | XCTAssertEqual(columns[0].column, "first") 57 | XCTAssertTrue(columns[0].ascending) 58 | 59 | XCTAssertEqual(columns[1].column, "second") 60 | XCTAssertFalse(columns[1].ascending) 61 | 62 | XCTAssertEqual(columns[2].column, "third") 63 | XCTAssertTrue(columns[2].ascending) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/Schema/SchemaTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PowerSync 3 | 4 | final class SchemaTests: XCTestCase { 5 | private func makeValidTable(name: String) -> Table { 6 | return Table( 7 | name: name, 8 | columns: [ 9 | Column.text("name"), 10 | Column.integer("age") 11 | ] 12 | ) 13 | } 14 | 15 | private func makeInvalidTable() -> Table { 16 | // Table with invalid column name 17 | return Table( 18 | name: "test", 19 | columns: [ 20 | Column.text("invalid name") 21 | ] 22 | ) 23 | } 24 | 25 | func testArrayInitialization() { 26 | let tables = [ 27 | makeValidTable(name: "users"), 28 | makeValidTable(name: "posts") 29 | ] 30 | 31 | let schema = Schema(tables: tables) 32 | 33 | XCTAssertEqual(schema.tables.count, 2) 34 | XCTAssertEqual(schema.tables[0].name, "users") 35 | XCTAssertEqual(schema.tables[1].name, "posts") 36 | } 37 | 38 | func testVariadicInitialization() { 39 | let schema = Schema( 40 | makeValidTable(name: "users"), 41 | makeValidTable(name: "posts") 42 | ) 43 | 44 | XCTAssertEqual(schema.tables.count, 2) 45 | XCTAssertEqual(schema.tables[0].name, "users") 46 | XCTAssertEqual(schema.tables[1].name, "posts") 47 | } 48 | 49 | func testEmptySchemaInitialization() { 50 | let schema = Schema(tables: []) 51 | XCTAssertTrue(schema.tables.isEmpty) 52 | XCTAssertNoThrow(try schema.validate()) 53 | } 54 | 55 | func testDuplicateTableValidation() { 56 | let schema = Schema( 57 | makeValidTable(name: "users"), 58 | makeValidTable(name: "users") 59 | ) 60 | 61 | XCTAssertThrowsError(try schema.validate()) { error in 62 | guard case SchemaError.duplicateTableName(let tableName) = error else { 63 | XCTFail("Expected duplicateTableName error") 64 | return 65 | } 66 | XCTAssertEqual(tableName, "users") 67 | } 68 | } 69 | 70 | func testCascadingTableValidation() { 71 | let schema = Schema( 72 | makeValidTable(name: "users"), 73 | makeInvalidTable() 74 | ) 75 | 76 | XCTAssertThrowsError(try schema.validate()) { error in 77 | // The error should be from the Table validation 78 | guard case TableError.invalidColumnName = error else { 79 | XCTFail("Expected invalidColumnName error from Table validation") 80 | return 81 | } 82 | } 83 | } 84 | 85 | func testValidSchemaValidation() { 86 | let schema = Schema( 87 | makeValidTable(name: "users"), 88 | makeValidTable(name: "posts"), 89 | makeValidTable(name: "comments") 90 | ) 91 | 92 | XCTAssertNoThrow(try schema.validate()) 93 | } 94 | 95 | func testSingleTableSchema() { 96 | let schema = Schema(makeValidTable(name: "users")) 97 | XCTAssertEqual(schema.tables.count, 1) 98 | XCTAssertNoThrow(try schema.validate()) 99 | } 100 | 101 | func testTableAccess() { 102 | let users = makeValidTable(name: "users") 103 | let posts = makeValidTable(name: "posts") 104 | 105 | let schema = Schema(users, posts) 106 | 107 | XCTAssertEqual(schema.tables[0].name, users.name) 108 | XCTAssertEqual(schema.tables[1].name, posts.name) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Tests/PowerSyncTests/test-utils/MockConnector.swift: -------------------------------------------------------------------------------- 1 | import PowerSync 2 | 3 | class MockConnector: PowerSyncBackendConnector { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /docs/LocalBuild.md: -------------------------------------------------------------------------------- 1 | # PowerSync Swift SDK 2 | 3 | ## Run against a local Kotlin build 4 | 5 | Especially when working on the Kotlin SDK, it may be helpful to test your local changes 6 | with the Swift SDK too. 7 | To do this, first create an XCFramework from your Kotlin checkout: 8 | 9 | ```bash 10 | ./gradlew PowerSyncKotlin:assemblePowerSyncKotlinDebugXCFramework 11 | ``` 12 | 13 | Next, you need to update the `Package.swift` to, instead of downloading a 14 | prebuilt XCFramework archive from a Kotlin release, use your local build. 15 | For this, set the `localKotlinSdkOverride` variable to your path: 16 | 17 | ```Swift 18 | let localKotlinSdkOverride: String? = "/path/to/powersync-kotlin/" 19 | ``` 20 | 21 | Subsequent Kotlin changes should get picked up after re-assembling the Kotlin XCFramework. 22 | -------------------------------------------------------------------------------- /docs/Release.md: -------------------------------------------------------------------------------- 1 | # PowerSync Swift SDK 2 | 3 | ## Releasing 4 | 5 | * Confirm every PR you want in the release has been merged into `main`. 6 | * Update `CHANGELOG.md` with the changes. 7 | * In GitHub actions on GitHub manually run the `Release PowerSync` action. You will be required to update the version and add release notes. 8 | The version string should have the form `1.0.0-beta.x` for beta releases, there should not be a `v` prefix on the tag name. 9 | * If the release notes are complicated and don't fit on a single line it is easier to rather update those after the release is completed by updating the release notes in the new release. 10 | --------------------------------------------------------------------------------