├── .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 |
--------------------------------------------------------------------------------