├── .github ├── ISSUE_TEMPLATE │ └── config.yml └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── README.md └── main.dart ├── lib ├── src │ ├── auth_user.dart │ ├── constants.dart │ ├── realtime_client_options.dart │ ├── remove_subscription_result.dart │ ├── supabase_client.dart │ ├── supabase_event_types.dart │ ├── supabase_query_builder.dart │ ├── supabase_realtime_error.dart │ ├── supabase_stream_builder.dart │ └── version.dart └── supabase.dart ├── pubspec.yaml └── test ├── client_test.dart ├── mock_test.dart └── realtime_test.dart /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Open an Issue 4 | url: https://github.com/supabase-community/supabase-flutter/issues/new/choose 5 | about: We consolidate all Dart/ Flutter related issues on the supabase-flutter repo. 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | name: Test / SDK ${{ matrix.sdk }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ubuntu-latest] 15 | node: ['12'] 16 | sdk: [2.15.0, stable, beta, dev] 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Checks-out repo 22 | uses: actions/checkout@v2 23 | 24 | - name: Setup Dart 25 | uses: dart-lang/setup-dart@v1 26 | with: 27 | sdk: ${{ matrix.sdk }} 28 | 29 | - name: Install dependencies 30 | run: dart pub get 31 | 32 | - name: dartfmt 33 | run: dart format lib test -l 80 --set-exit-if-changed 34 | 35 | - name: analyzer 36 | run: dart analyze --fatal-warnings --fatal-infos . 37 | 38 | - name: Run tests 39 | run: dart test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | .DS_Store 3 | 4 | # Files and directories created by pub 5 | .dart_tool/ 6 | .packages 7 | 8 | # Omit commiting pubspec.lock for library packages: 9 | # https://dart.dev/guides/libraries/private-files#pubspeclock 10 | pubspec.lock 11 | 12 | # Specifying what packages to use in the pubspec_overrides 13 | # https://github.com/dart-lang/pub/pull/3215 14 | pubspec_overrides.yaml 15 | 16 | # Conventional directory for build outputs 17 | build/ 18 | 19 | # Directory created by dartdoc 20 | doc/api/ 21 | 22 | # General editor configs 23 | .vscode/ 24 | .idea/ 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.8.0] 2 | - feat: make the headers property editable [#185](https://github.com/supabase/supabase-dart/pull/185) 3 | ```dart 4 | // Add custom headers using the `headers` setter 5 | supabase.headers = {'my-headers': 'my-value'}; 6 | ``` 7 | 8 | ## [1.7.0] 9 | - feat: add async storage as parameter to support pkce flow [#190](https://github.com/supabase/supabase-dart/pull/190) 10 | - fix: use onAuthStateChangeSync to set auth headers [#193](https://github.com/supabase/supabase-dart/pull/193) 11 | 12 | ## [1.6.4] 13 | - fix: race condition for passing auth headers for rest client [#192](https://github.com/supabase/supabase-dart/pull/192) 14 | 15 | 16 | ## [1.6.3] 17 | - fix: copy headers value on from() call [#189](https://github.com/supabase/supabase-dart/pull/189) 18 | 19 | ## [1.6.2] 20 | - fix: handle onAuthStateChange errors silently [#187](https://github.com/supabase/supabase-dart/pull/187) 21 | - fix: persist a single postgrest client [#186](https://github.com/supabase/supabase-dart/pull/186) 22 | 23 | ## [1.6.1] 24 | - fix: update storage to v1.2.3 25 | - add `setAuth()` function 26 | - fix: keep one storage and functions instance to persist auth [#182](https://github.com/supabase/supabase-dart/pull/182) 27 | 28 | ## [1.6.0] 29 | - feat: update gotrue to v1.5.1 30 | - add support for `signInWithIdToken` 31 | - feat: update functions_client to v1.1.0 32 | - add method parameter to invoke() to support all GET, POST, PUT, PATCH, DELETE methods 33 | 34 | ## [1.5.1] 35 | 36 | - fix: reuse isolate for `.rpc()` call [#177](https://github.com/supabase/supabase-dart/pull/177) 37 | 38 | ## [1.5.0] 39 | 40 | - feat: add `realtimeClientOptions` to SupabaseClient [#173](https://github.com/supabase/supabase-dart/pull/173) 41 | - fix: add missing `options` parameter to rpc [#174](https://github.com/supabase/supabase-dart/pull/174) 42 | - fix: update postgrest to v1.2.2 43 | - improve comment docs 44 | - deprecate `returning` parameter of `.delete()` 45 | - fix: update storage to v1.2.2 46 | - properly parse content type 47 | ## [1.4.0] 48 | 49 | - feat: use single isolate for functions and postgrest and add `isolate` parameter to `SupabaseClient` [#169](https://github.com/supabase/supabase-dart/pull/169) 50 | - fix: update gotrue to v1.4.1 51 | - `onAuthStateChanged` now emits the latest `AuthState` 52 | - downgrade minimum `collection` version to support wider range of Flutter SDK versions 53 | - fix: update storage to v1.2.1 54 | - correct path parameter documentation 55 | 56 | 57 | ## [1.3.0] 58 | 59 | - fix: handle update and delete on record that wasn't found properly using stream [#167](https://github.com/supabase/supabase-dart/pull/167) 60 | - feat: update gotrue to v1.4.0 61 | - add support for [MFA](https://supabase.com/docs/guides/auth/auth-mfa) 62 | ```dart 63 | // Start the enrollment process for a new Multi-Factor Authentication (MFA) factor 64 | final res = await client.mfa 65 | .enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName'); 66 | 67 | // Prepares a challenge used to verify that a user has access to a MFA factor. 68 | final res = await client.mfa.challenge(factorId: factorId1); 69 | 70 | // Verifies a code against a challenge. 71 | final res = await client.mfa 72 | .verify(factorId: factorId1, challengeId: challengeId, code: getTOTP()); 73 | ``` 74 | Read more about MFA with Supabase [here](https://supabase.com/docs/guides/auth/auth-mfa) 75 | - paginate `admin.listUsers()` 76 | ```dart 77 | auth.admin.listUsers(page: 2, perPage: 10); 78 | ``` 79 | - feat: update postgrest to v1.2.1 80 | - fix: update realtime to v1.0.2 81 | - export realtime presence 82 | - feat: update storage to v1.2.0 83 | - add transform option to `createSignedUrl()`, `getPublicUrl()`, and `.download()` to transform images on the fly 84 | ```dart 85 | final signedUrl = await storage.from(newBucketName).createSignedUrl(uploadPath, 2000, 86 | transform: TransformOptions( 87 | width: 100, 88 | height: 100, 89 | )); 90 | final publicUrl = storage.from(bucket).getPublicUrl(path, 91 | transform: TransformOptions(width: 200, height: 300)); 92 | final file = await storage.from(newBucketName).download(uploadPath, 93 | transform: TransformOptions( 94 | width: 200, 95 | height: 200, 96 | )); 97 | ``` 98 | 99 | 100 | ## [1.2.0] 101 | 102 | - feat: add storage retry option to enable storage to auto retry failed upload attempts [(#163)](https://github.com/supabase/supabase-dart/pull/163) 103 | ```dart 104 | // The following will initialize a supabase client that will retry failed uploads up to 25 times, 105 | // which is about 10 minutes of retrying. 106 | final supabase = SupabaseClient('Supabase_URL', 'Anon_key', storageRetryAttempts: 25); 107 | ``` 108 | - feat: update storage to v1.1.0 109 | - feat: update gotrue to v1.2.0 110 | - add createUser(), deleteUser(), and listUsers() to admin methods. 111 | 112 | 113 | ## [1.1.2] 114 | 115 | - fix: enable listening to the same stream multiple times [(#161)](https://github.com/supabase/supabase-dart/pull/161) 116 | 117 | ## [1.1.1] 118 | 119 | - fix: update postgrest to v1.1.1 120 | - fix: implement asyncMap and asyncExpand [(#159)](https://github.com/supabase/supabase-dart/pull/159) 121 | 122 | ## [1.1.0] 123 | 124 | - fix: stream filter other than eq is not properly applied. [(#156)](https://github.com/supabase-community/supabase-dart/pull/156) 125 | - fix: update examples [(#157)](https://github.com/supabase-community/supabase-dart/pull/157) 126 | - feat: update gotrue to v1.1.1 127 | - fail to getSessionFromUrl throws error on `onAuthStateChange` 128 | ```dart 129 | supabase.onAuthStateChange.listen((data) { 130 | // handle auth state change here 131 | }, onError: (error) { 132 | // handle error here 133 | }); 134 | ``` 135 | - feat: update postgrest to v1.1.0 136 | - feat: add generic types to `.select()` 137 | ```dart 138 | // data is `List>` 139 | final data = await supabase.from>>('users').select(); 140 | 141 | // data is `Map` 142 | final data = await supabase.from>('users').select().eq('id', myId).single(); 143 | ``` 144 | 145 | 146 | ## [1.0.1] 147 | 148 | - fix: update sample code on readme.md 149 | 150 | ## [1.0.0] 151 | 152 | - chore: v1.0.0 release 🚀 153 | - BREAKING: set minimum SDK of Dart at 2.15.0 [(#150)](https://github.com/supabase-community/supabase-dart/pull/150) 154 | - BREAKING: `.stream()` now takes a named parameter `primaryKey` instead of a positional argument. 155 | ```dart 156 | supabase.from('my_table').stream(primaryKey: ['id']); 157 | ``` 158 | - feat: `.stream()` has 5 additional filters: `neq`, `gt`, `gte`, `lt`, `lte` [(#148)](https://github.com/supabase-community/supabase-dart/pull/148) 159 | - chore: update postgrest to v1.0.0 160 | - chore: update realtime to v1.0.0 161 | - chore: update storage to v1.0.0 162 | - chore: update functions to v1.0.0 163 | - BREAKING: update gotrue to v1.0.0 164 | - `signUp()` now uses named parameters 165 | ```dart 166 | // Before 167 | final res = await supabase.auth.signUp('example@email.com', 'password'); 168 | // After 169 | final res = await supabase.auth.signUp(email: 'example@email.com', password: 'password'); 170 | ``` 171 | - `signIn()` is split into different methods 172 | ```dart 173 | // Magic link signin 174 | // Before 175 | final res = await supabase.auth.signIn(email: 'example@email.com'); 176 | // After 177 | final res = await supabase.auth.signInWithOtp(email: 'example@email.com'); 178 | 179 | // Email and password signin 180 | // Before 181 | final res = await supabase.auth.signIn(email: 'example@email.com', password: 'password'); 182 | // After 183 | final res = await supabase.auth.signInWithPassword(email: 'example@email.com', password: 'password'); 184 | ``` 185 | - `onAuthStateChange` is now a stream 186 | ```dart 187 | // Before 188 | supabase.auth.onAuthStateChange((event, session) { 189 | // ... 190 | }); 191 | // After 192 | final subscription = supabase.auth.onAuthStateChange().listen((data) { 193 | final AuthChangeEvent event = data.event; 194 | final Session? session = data.session; 195 | }); 196 | // Don't forget to cancel the subscription when you're done 197 | subscription.cancel(); 198 | ``` 199 | - `update()` is renamed to `updateUser()` 200 | ```dart 201 | // Before 202 | final res = await supabase.auth.update( 203 | UserAttributes( 204 | email: 'new@email.com', 205 | data: { 206 | 'username': 'new_username', 207 | }, 208 | ), 209 | ); 210 | // After 211 | final res = await supabase.auth.updateUser( 212 | UserAttributes( 213 | email: 'new@email.com', 214 | data: { 215 | 'username': 'new_username', 216 | }, 217 | ), 218 | ); 219 | ``` 220 | 221 | ## [1.0.0-dev.9] 222 | 223 | - fix: update realtime to [v1.0.0-dev.5](https://github.com/supabase-community/realtime-dart/blob/main/CHANGELOG.md#100-dev5) 224 | - fix: sends null for access_token when not signed in [(#53)](https://github.com/supabase-community/realtime-dart/pull/53) 225 | 226 | ## [1.0.0-dev.8] 227 | 228 | - fix: recreate a `PostgrestClient` with proper auth headers when calling `.rpc()` [(#143)](https://github.com/supabase-community/supabase-dart/pull/143) 229 | - fix: allow custom headers to be set for `SupabaseClient` [(#144)](https://github.com/supabase-community/supabase-dart/pull/144) 230 | - fix: stream error will emit the entire exception and the stack trace [(#145)](https://github.com/supabase-community/supabase-dart/pull/145) 231 | - fix: update realtime to [v1.0.0-dev.4](https://github.com/supabase-community/realtime-dart/blob/main/CHANGELOG.md#100-dev4) 232 | - fix: bug where it throws exception when listening to postgres changes on old version of realtime server 233 | 234 | ## [1.0.0-dev.7] 235 | 236 | - BREAKING: update relatime to [v1.0.0-dev.3](https://github.com/supabase-community/realtime-dart/blob/main/CHANGELOG.md#100-dev3) 237 | - update payload shape on old version of realtime server to match the new version 238 | - fix: update gotrue to [v1.0.0-dev.4](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#100-dev4) 239 | - fix: encoding issue with some languages 240 | - fix: update postgrest to [v1.0.0-dev.4](https://github.com/supabase-community/postgrest-dart/blob/master/CHANGELOG.md#100-dev4) 241 | - fix: update insert documentation to reflect new `returning` behavior 242 | 243 | ## [1.0.0-dev.6] 244 | 245 | - fix: `.stream()` method typing issue 246 | 247 | ## [1.0.0-dev.5] 248 | 249 | - BREAKING: update realtime to [v1.0.0-dev.2](https://github.com/supabase-community/realtime-dart/blob/main/CHANGELOG.md#100-dev2) 250 | - deprecated: `.execute()` and `.stream()` can be used without it 251 | - BREAKING: filters on `.stream()` no longer takes the realtime syntax. `.eq()` method should be used to apply `eq` filter on `.stream()`. 252 | ```dart 253 | // before 254 | supabase.from('my_table:title=eq.Supabase') 255 | .stream(['id']) 256 | .order('created_at') 257 | .limit(10) 258 | .execute() 259 | .listen((payload) { 260 | // do something with payload here 261 | }); 262 | 263 | // now 264 | supabase.from('my_table') 265 | .stream(['id']) 266 | .eq('title', 'Supabase') 267 | .order('created_at') 268 | .limit(10) 269 | .listen((payload) { 270 | // do something with payload here 271 | }); 272 | ``` 273 | 274 | ## [1.0.0-dev.4] 275 | 276 | - fix: update storage to [v1.0.0-dev.3](https://github.com/supabase-community/storage-dart/blob/main/CHANGELOG.md#100-dev3) 277 | - fix: add `web_socket_channel` to dev dependencies since it is used in tests 278 | - fix: add basic `postgrest` test 279 | - BREAKING: update gotrue to [v1.0.0-dev.3](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#100-dev3) 280 | 281 | ## [1.0.0-dev.3] 282 | 283 | - fix: export storage types 284 | - BREAKING: update postgrest to [v1.0.0-dev.2](https://github.com/supabase-community/postgrest-dart/blob/master/CHANGELOG.md#100-dev2) 285 | - BREAKING: update gotrue to [v1.0.0-dev.2](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#100-dev2) 286 | - feat: update storage to [v1.0.0-dev.2](https://github.com/supabase-community/storage-dart/blob/main/CHANGELOG.md#100-dev2) 287 | 288 | ## [1.0.0-dev.2] 289 | 290 | - feat: custom http client 291 | 292 | ## [1.0.0-dev.1] 293 | 294 | - BREAKING: update postgrest to [v1.0.0-dev.1](https://github.com/supabase-community/postgrest-dart/blob/master/CHANGELOG.md#100-dev1) 295 | - BREAKING: update gotrue to [v1.0.0-dev.1](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#100-dev1) 296 | - BREAKING: update storage to [v1.0.0-dev.1](https://github.com/supabase-community/storage-dart/blob/main/CHANGELOG.md#100-dev1) 297 | - BREAKING: update functions to [v1.0.0-dev.1](https://github.com/supabase-community/functions-dart/blob/main/CHANGELOG.md#100-dev1) 298 | 299 | ## [0.3.6] 300 | 301 | - fix: Calling postgrest endpoints within realtime callback throws exception 302 | - feat: update gotrue to [v0.2.3](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#023) 303 | 304 | ## [0.3.5] 305 | 306 | - fix: update gotrue to [v0.2.2+1](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#0221) 307 | - feat: update postgrest to [v0.1.11](https://github.com/supabase-community/postgrest-dart/blob/master/CHANGELOG.md#0111) 308 | - fix: flaky test on stream() 309 | 310 | ## [0.3.4+1] 311 | 312 | - fix: export type, `SupabaseRealtimePayload` 313 | 314 | ## [0.3.4] 315 | 316 | - fix: update gotrue to [v0.2.2](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#022) 317 | 318 | ## [0.3.3] 319 | 320 | - feat: update gotrue to[v0.2.1](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#021) 321 | - fix: update postgrest to[0.1.10+1](https://github.com/supabase-community/postgrest-dart/blob/master/CHANGELOG.md#01101) 322 | 323 | ## [0.3.2] 324 | 325 | - feat: update postgrest to [v0.1.10](https://github.com/supabase-community/postgrest-dart/blob/master/CHANGELOG.md#0110) 326 | - fix: update functions_client to [v0.0.1-dev.4](https://github.com/supabase-community/functions-dart/blob/main/CHANGELOG.md#001-dev4) 327 | 328 | ## [0.3.1+1] 329 | 330 | - feat: exporting classes of functions_client 331 | 332 | ## [0.3.1] 333 | 334 | - feat: add functions support 335 | 336 | ## [0.3.0] 337 | 338 | - BREAKING: update gotrue_client to [v0.2.0](https://github.com/supabase-community/gotrue-dart/blob/main/CHANGELOG.md#020) 339 | 340 | ## [0.2.15] 341 | 342 | - chore: update gotrue_client to v0.1.6 343 | 344 | ## [0.2.14] 345 | 346 | - chore: update gotrue_client to v0.1.5 347 | - chore: update postgrest to v0.1.9 348 | - chore: update realtime_client to v0.1.15 349 | - chore: update storage_client to v0.0.6+2 350 | 351 | ## [0.2.13] 352 | 353 | - chore: update realtime_client to v0.1.14 354 | 355 | ## [0.2.12] 356 | 357 | - fix: changedAccessToken never initialized error when changing account 358 | - fix: stream replaces the correct row 359 | 360 | ## [0.2.11] 361 | 362 | - feat: listen for auth event and handle token changed 363 | - chore: update gotrue to v0.1.3 364 | - chore: update realtime_client to v0.1.13 365 | - fix: use PostgrestFilterBuilder type for rpc 366 | - docs: correct stream method documentation 367 | 368 | ## [0.2.10] 369 | 370 | - fix: type 'Null' is not a subtype of type 'List' in type cast 371 | 372 | ## [0.2.9] 373 | 374 | - feat: add user_token when creating realtime channel subscription 375 | - fix: typo on Realtime data as Stream on readme.md 376 | 377 | ## [0.2.8] 378 | 379 | - chore: update gotrue to v0.1.2 380 | - chore: update storage_client to v0.0.6 381 | - fix: cleanup imports in `supabase_stream_builder` to remove analysis error 382 | 383 | ## [0.2.7] 384 | 385 | - chore: update postgrest to v0.1.8 386 | 387 | ## [0.2.6] 388 | 389 | - chore: add `X-Client-Info` header 390 | - chore: update gotrue to v0.1.1 391 | - chore: update postgrest to v0.1.7 392 | - chore: update realtime_client to v0.1.11 393 | - chore: update storage_client to v0.0.5 394 | 395 | ## [0.2.5] 396 | 397 | - chore: update realtime_client to v0.1.10 398 | 399 | ## [0.2.4] 400 | 401 | - chore: update postgrest to v0.1.6 402 | 403 | ## [0.2.3] 404 | 405 | - chore: update realtime_client to v0.1.9 406 | 407 | ## [0.2.2] 408 | 409 | - fix: bug where `stream()` tries to emit data when `StreamController` is closed 410 | 411 | ## [0.2.1] 412 | 413 | - chore: update realtime_client to v0.1.8 414 | 415 | ## [0.2.0] 416 | 417 | - feat: added `stream()` method to listen to realtime updates as stream 418 | 419 | ## [0.1.0] 420 | 421 | - chore: update gotrue to v0.1.0 422 | - feat: add phone auth 423 | 424 | ## [0.0.8] 425 | 426 | - chore: update postgrest to v0.1.5 427 | - chore: update storage_client to v0.0.4 428 | 429 | ## [0.0.7] 430 | 431 | - chore: update realtime_client to v0.1.7 432 | 433 | ## [0.0.6] 434 | 435 | - chore: update realtime_client to v0.1.6 436 | 437 | ## [0.0.5] 438 | 439 | - chore: update realtime_client to v0.1.5 440 | 441 | ## [0.0.4] 442 | 443 | - chore: update realtime_client to v0.1.4 444 | 445 | ## [0.0.3] 446 | 447 | - chore: update storage_client to v0.0.3 448 | 449 | ## [0.0.2] 450 | 451 | - chore: update gotrue to v0.0.7 452 | - chore: update postgrest to v0.1.4 453 | - chore: update storage_client to v0.0.2 454 | 455 | ## [0.0.1] 456 | 457 | - chore: update storage_client to v0.0.1 458 | - Initial Release 459 | 460 | ## [0.0.1-dev.27] 461 | 462 | - chore: update realtime to v0.1.3 463 | 464 | ## [0.0.1-dev.26] 465 | 466 | - chore: update gotrue to v0.0.6 467 | 468 | ## [0.0.1-dev.25] 469 | 470 | - chore: update realtime to v0.1.2 471 | 472 | ## [0.0.1-dev.24] 473 | 474 | - fix: export postgrest classes 475 | 476 | ## [0.0.1-dev.23] 477 | 478 | - chore: update realtime to v0.1.1 479 | 480 | ## [0.0.1-dev.22] 481 | 482 | - chore: update gotrue to v0.0.5 483 | 484 | ## [0.0.1-dev.21] 485 | 486 | - chore: update realtime to v0.1.0 487 | 488 | ## [0.0.1-dev.20] 489 | 490 | - chore: update gotrue to v0.0.4 491 | 492 | ## [0.0.1-dev.19] 493 | 494 | - chore: update gotrue to v0.0.3 495 | 496 | ## [0.0.1-dev.18] 497 | 498 | - chore: update gotrue to v0.0.2 499 | - chore: update postgrest to v0.1.3 500 | - chore: update storage_client to v0.0.1-dev.3 501 | 502 | ## [0.0.1-dev.17] 503 | 504 | - chore: update realtime to v0.0.9 505 | - chore: update postgrest to v0.1.2 506 | 507 | ## [0.0.1-dev.16] 508 | 509 | - chore: update storage_client to v0.0.1-dev.2 510 | - chore: update gotrue to v0.0.1 511 | 512 | ## [0.0.1-dev.15] 513 | 514 | - chore: update postgrest to v0.1.1 515 | - chore: update gotrue to v0.0.1-dev.11 516 | 517 | ## [0.0.1-dev.14] 518 | 519 | - refactor: use storage_client package v0.0.1-dev.1 520 | 521 | ## [0.0.1-dev.13] 522 | 523 | - fix: package dependencies 524 | 525 | ## [0.0.1-dev.12] 526 | 527 | - feat: implement Storage API 528 | - chore: update postgrest to v0.1.0 529 | - chore: update gotrue to v0.0.1-dev.10 530 | 531 | ## [0.0.1-dev.11] 532 | 533 | - fix: aligned exports with supabase-js 534 | 535 | ## [0.0.1-dev.10] 536 | 537 | - chore: migrate to null-safety 538 | 539 | ## [0.0.1-dev.9] 540 | 541 | - fix: rpc to return PostgrestTransformBuilder 542 | - chore: update postgrest to v0.0.7 543 | - chore: expose gotrue User as AuthUser 544 | - chore: expose 'RealtimeSubscription' 545 | - chore: update lib description 546 | 547 | ## [0.0.1-dev.8] 548 | 549 | - fix: rpc method missing param name 550 | 551 | ## [0.0.1-dev.8] 552 | 553 | - chore: update postgrest ^0.0.6 554 | 555 | ## [0.0.1-dev.6] 556 | 557 | - chore: update gotrue v0.0.1-dev.7 558 | - chore: update realtime_client v0.0.7 559 | 560 | ## [0.0.1-dev.5] 561 | 562 | - refactor: SupabaseRealtimePayload variable names 563 | 564 | ## [0.0.1-dev.4] 565 | 566 | - fix: export SupabaseEventTypes 567 | - chore: include realtime supscription code in example 568 | 569 | ## [0.0.1-dev.3] 570 | 571 | - fix: SupabaseRealtimeClient client and payload parsing bug 572 | - update: realtime_client to v0.0.5 573 | 574 | ## [0.0.1-dev.2] 575 | 576 | - fix: builder method not injecting table in the url 577 | 578 | ## [0.0.1-dev.1] 579 | 580 | - Initial pre-release. 581 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Supabase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `supabase-dart` 2 | 3 | > **Warning** 4 | > This repository has been moved to the [supabase-flutter repo](https://github.com/supabase/supabase-flutter/tree/main/packages/supabase). 5 | 6 | A Dart client for [Supabase](https://supabase.io/). 7 | 8 | > **Note** 9 | > 10 | > If you are developing a Flutter application, use [supabase_flutter](https://pub.dev/packages/supabase_flutter) instead. `supabase` package is for non-Flutter Dart environments. 11 | 12 | --- 13 | 14 | ## What is Supabase 15 | 16 | [Supabase](https://supabase.io/docs/) is an open source Firebase alternative. We are a service to: 17 | 18 | - listen to database changes 19 | - query your tables, including filtering, pagination, and deeply nested relationships (like GraphQL) 20 | - create, update, and delete rows 21 | - manage your users and their permissions 22 | - interact with your database using a simple UI 23 | 24 | ## Status 25 | 26 | Public Beta: Stable. No breaking changes expected in this version but possible bugs. 27 | 28 | ## Docs 29 | 30 | Find the documentation [here](https://supabase.com/docs/reference/dart/initializing). 31 | 32 | ## Usage example 33 | 34 | ### [Database](https://supabase.io/docs/guides/database) 35 | 36 | ```dart 37 | import 'package:supabase/supabase.dart'; 38 | 39 | main() { 40 | final supabase = SupabaseClient('supabaseUrl', 'supabaseKey'); 41 | 42 | // Select from table `countries` ordering by `name` 43 | final data = await supabase 44 | .from('countries') 45 | .select() 46 | .order('name', ascending: true); 47 | } 48 | ``` 49 | 50 | ### [Realtime](https://supabase.io/docs/guides/database#realtime) 51 | 52 | ```dart 53 | import 'package:supabase/supabase.dart'; 54 | 55 | main() { 56 | final supabase = SupabaseClient('supabaseUrl', 'supabaseKey'); 57 | 58 | // Set up a listener to listen to changes in `countries` table 59 | supabase.channel('my_channel').on(RealtimeListenTypes.postgresChanges, ChannelFilter( 60 | event: '*', 61 | schema: 'public', 62 | table: 'countries' 63 | ), (payload, [ref]) { 64 | // Do something when there is an update 65 | }).subscribe(); 66 | 67 | // remember to remove the channels when you're done 68 | supabase.removeAllChannels(); 69 | } 70 | ``` 71 | 72 | ### Realtime data as `Stream` 73 | 74 | To receive realtime updates, you have to first enable Realtime on from your Supabase console. You can read more [here](https://supabase.io/docs/guides/api#managing-realtime) on how to enable it. 75 | 76 | ```dart 77 | import 'package:supabase/supabase.dart'; 78 | 79 | main() { 80 | final supabase = SupabaseClient('supabaseUrl', 'supabaseKey'); 81 | 82 | // Set up a listener to listen to changes in `countries` table 83 | final subscription = supabase 84 | .from('countries') 85 | .stream(primaryKey: ['id']) // Pass list of primary key column names 86 | .order('name') 87 | .limit(30) 88 | .listen(_handleCountriesStream); 89 | 90 | // remember to remove subscription when you're done 91 | subscription.cancel(); 92 | } 93 | ``` 94 | 95 | ### [Authentication](https://supabase.io/docs/guides/auth) 96 | 97 | This package does not persist auth state automatically. Use [supabase_flutter](https://pub.dev/packages/supabase_flutter) for Flutter apps to persist auth state instead of this package. 98 | 99 | ```dart 100 | import 'package:supabase/supabase.dart'; 101 | 102 | main() { 103 | final supabase = SupabaseClient('supabaseUrl', 'supabaseKey'); 104 | 105 | // Sign up user with email and password 106 | final response = await supabase 107 | .auth 108 | .signUp(email: 'sample@email.com', password: 'password'); 109 | } 110 | ``` 111 | 112 | ### [Storage](https://supabase.io/docs/guides/storage) 113 | 114 | ```dart 115 | import 'package:supabase/supabase.dart'; 116 | 117 | main() { 118 | final supabase = SupabaseClient('supabaseUrl', 'supabaseKey'); 119 | 120 | // Create file `example.txt` and upload it in `public` bucket 121 | final file = File('example.txt'); 122 | file.writeAsStringSync('File content'); 123 | final storageResponse = await supabase 124 | .storage 125 | .from('public') 126 | .upload('example.txt', file); 127 | } 128 | ``` 129 | 130 | Check out the [**Official Documentation**](https://supabase.com/docs/reference/dart/) to learn all the other available methods. 131 | 132 | 133 | ## Contributing 134 | 135 | - Fork the repo on [GitHub](https://github.com/supabase/supabase-dart) 136 | - Clone the project to your own machine 137 | - Commit changes to your own branch 138 | - Push your work back up to your fork 139 | - Submit a Pull request so that we can review your changes and merge 140 | 141 | ## License 142 | 143 | This repo is licenced under MIT. 144 | 145 | ## Credits 146 | 147 | - https://github.com/supabase/supabase-js 148 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | 3 | linter: 4 | rules: 5 | avoid_print: false -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | #### Examples 2 | 3 | - Flutter user management: https://github.com/supabase/supabase/tree/master/examples/flutter-user-management 4 | - Extended flutter user management with web support, github login, recovery password flow 5 | - https://github.com/phamhieu/supabase-flutter-demo 6 | - Spot, open source geo based video sharing social app created with Flutter 7 | - https://github.com/dshukertjr/spot 8 | - Notes app: https://github.com/bigblackclock/supabase_notes 9 | -------------------------------------------------------------------------------- /example/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:supabase/supabase.dart'; 5 | 6 | Future main() async { 7 | const supabaseUrl = 'YOUR_SUPABASE_URL'; 8 | const supabaseKey = 'YOUR_ANON_KEY'; 9 | final supabase = SupabaseClient(supabaseUrl, supabaseKey); 10 | 11 | // query data 12 | final data = 13 | await supabase.from('countries').select().order('name', ascending: true); 14 | print(data); 15 | 16 | // insert data 17 | await supabase.from('countries').insert([ 18 | {'name': 'Singapore'}, 19 | ]); 20 | 21 | // update data 22 | await supabase.from('countries').update({'name': 'Singapore'}).eq('id', 1); 23 | 24 | // delete data 25 | await supabase.from('countries').delete().eq('id', 1); 26 | 27 | // realtime 28 | final realtimeChannel = supabase.channel('my_channel'); 29 | realtimeChannel 30 | .on( 31 | RealtimeListenTypes.postgresChanges, 32 | ChannelFilter(event: '*', schema: 'public', table: 'countries'), 33 | (payload, [ref]) {}) 34 | .subscribe(); 35 | 36 | // remember to remove channel when no longer needed 37 | supabase.removeChannel(realtimeChannel); 38 | 39 | // stream 40 | final streamSubscription = supabase 41 | .from('countries') 42 | .stream(primaryKey: ['id']) 43 | .order('name') 44 | .limit(10) 45 | .listen((snapshot) { 46 | print('snapshot: $snapshot'); 47 | }); 48 | 49 | // remember to remove subscription 50 | streamSubscription.cancel(); 51 | 52 | // Upload file to bucket "public" 53 | final file = File('example.txt'); 54 | file.writeAsStringSync('File content'); 55 | final storageResponse = 56 | await supabase.storage.from('public').upload('example.txt', file); 57 | print('upload response : $storageResponse'); 58 | 59 | // Get download url 60 | final urlResponse = 61 | await supabase.storage.from('public').createSignedUrl('example.txt', 60); 62 | print('download url : $urlResponse'); 63 | 64 | // Download text file 65 | final fileResponse = 66 | await supabase.storage.from('public').download('example.txt'); 67 | print('downloaded file : ${String.fromCharCodes(fileResponse)}'); 68 | 69 | // Delete file 70 | final deleteFileResponse = 71 | await supabase.storage.from('public').remove(['example.txt']); 72 | print('deleted file id : ${deleteFileResponse.first.id}'); 73 | 74 | // Local file cleanup 75 | if (file.existsSync()) file.deleteSync(); 76 | } 77 | -------------------------------------------------------------------------------- /lib/src/auth_user.dart: -------------------------------------------------------------------------------- 1 | import 'package:gotrue/gotrue.dart' show User; 2 | 3 | class AuthUser extends User { 4 | AuthUser({ 5 | required String id, 6 | required Map appMetadata, 7 | required Map userMetadata, 8 | required String aud, 9 | required String? email, 10 | required String? phone, 11 | required String createdAt, 12 | String? confirmedAt, 13 | String? emailConfirmedAt, 14 | String? phoneConfirmedAt, 15 | String? lastSignInAt, 16 | required String role, 17 | required String updatedAt, 18 | }) : super( 19 | id: id, 20 | appMetadata: appMetadata, 21 | userMetadata: userMetadata, 22 | aud: aud, 23 | email: email, 24 | phone: phone, 25 | createdAt: createdAt, 26 | // ignore: deprecated_member_use 27 | confirmedAt: confirmedAt, 28 | emailConfirmedAt: emailConfirmedAt, 29 | phoneConfirmedAt: phoneConfirmedAt, 30 | lastSignInAt: lastSignInAt, 31 | role: role, 32 | updatedAt: updatedAt, 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/constants.dart: -------------------------------------------------------------------------------- 1 | import 'package:supabase/src/version.dart'; 2 | 3 | class Constants { 4 | static const Map defaultHeaders = { 5 | 'X-Client-Info': 'supabase-dart/$version', 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /lib/src/realtime_client_options.dart: -------------------------------------------------------------------------------- 1 | /// {@template realtime_client_options} 2 | /// Options to pass to the RealtimeClient 3 | /// {@endtemplate} 4 | class RealtimeClientOptions { 5 | /// How many events the RealtimeClient can push in a second 6 | final int? eventsPerSecond; 7 | 8 | /// {@macro realtime_client_options} 9 | const RealtimeClientOptions({this.eventsPerSecond}); 10 | } 11 | -------------------------------------------------------------------------------- /lib/src/remove_subscription_result.dart: -------------------------------------------------------------------------------- 1 | import 'package:supabase/src/supabase_realtime_error.dart'; 2 | 3 | class RemoveSubscriptionResult { 4 | const RemoveSubscriptionResult({required this.openSubscriptions, this.error}); 5 | final int openSubscriptions; 6 | final SupabaseRealtimeError? error; 7 | 8 | @override 9 | String toString() => 10 | 'RemoveSubscriptionResult(openSubscriptions: $openSubscriptions, error: $error)'; 11 | } 12 | -------------------------------------------------------------------------------- /lib/src/supabase_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:functions_client/functions_client.dart'; 4 | import 'package:gotrue/gotrue.dart'; 5 | import 'package:http/http.dart'; 6 | import 'package:postgrest/postgrest.dart'; 7 | import 'package:realtime_client/realtime_client.dart'; 8 | import 'package:storage_client/storage_client.dart'; 9 | import 'package:supabase/src/constants.dart'; 10 | import 'package:supabase/src/realtime_client_options.dart'; 11 | import 'package:supabase/src/supabase_query_builder.dart'; 12 | import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; 13 | 14 | /// {@template supabase_client} 15 | /// Creates a Supabase client to interact with your Supabase instance. 16 | /// 17 | /// [supabaseUrl] and [supabaseKey] can be found on your Supabase dashboard. 18 | /// 19 | /// You can access none public schema by passing different [schema]. 20 | /// 21 | /// Default headers can be overridden by specifying [headers]. 22 | /// 23 | /// Custom http client can be used by passing [httpClient] parameter. 24 | /// 25 | /// [storageRetryAttempts] specifies how many retry attempts there should be to 26 | /// upload a file to Supabase storage when failed due to network interruption. 27 | /// 28 | /// [realtimeClientOptions] specifies different options you can pass to `RealtimeClient`. 29 | /// 30 | /// Pass an instance of `YAJsonIsolate` to [isolate] to use your own persisted 31 | /// isolate instance. A new instance will be created if [isolate] is omitted. 32 | /// 33 | /// Pass an instance of [gotrueAsyncStorage] and set the [authFlowType] to 34 | /// `AuthFlowType.pkce`in order to perform auth actions with pkce flow. 35 | /// {@endtemplate} 36 | class SupabaseClient { 37 | final String supabaseUrl; 38 | final String supabaseKey; 39 | final String schema; 40 | final String restUrl; 41 | final String realtimeUrl; 42 | final String authUrl; 43 | final String storageUrl; 44 | final String functionsUrl; 45 | final Map _headers; 46 | final Client? _httpClient; 47 | 48 | late final GoTrueClient auth; 49 | 50 | /// Supabase Functions allows you to deploy and invoke edge functions. 51 | late final FunctionsClient functions; 52 | 53 | /// Supabase Storage allows you to manage user-generated content, such as photos or videos. 54 | late final SupabaseStorageClient storage; 55 | late final RealtimeClient realtime; 56 | late final PostgrestClient rest; 57 | String? _changedAccessToken; 58 | late StreamSubscription _authStateSubscription; 59 | late final YAJsonIsolate _isolate; 60 | 61 | /// Increment ID of the stream to create different realtime topic for each stream 62 | int _incrementId = 0; 63 | 64 | /// Number of retries storage client should do on file failed file uploads. 65 | final int _storageRetryAttempts; 66 | 67 | /// Getter for the HTTP headers 68 | Map get headers { 69 | return _headers; 70 | } 71 | 72 | /// To apply the new headers in existing realtime channels, manually unsubscribe and resubscribe these channels. 73 | set headers(Map headers) { 74 | _headers.clear(); 75 | _headers.addAll({ 76 | ...Constants.defaultHeaders, 77 | ..._getAuthHeaders(), 78 | ...headers, 79 | }); 80 | 81 | rest.headers 82 | ..clear() 83 | ..addAll(_headers); 84 | 85 | functions.headers 86 | ..clear() 87 | ..addAll(_headers); 88 | 89 | storage.headers 90 | ..clear() 91 | ..addAll(_headers); 92 | 93 | auth.headers 94 | ..clear() 95 | ..addAll(_headers); 96 | 97 | // To apply the new headers in the realtime client, 98 | // manually unsubscribe and resubscribe to all channels. 99 | realtime.headers 100 | ..clear() 101 | ..addAll(_headers); 102 | } 103 | 104 | /// Creates a Supabase client to interact with your Supabase instance. 105 | /// 106 | /// [supabaseUrl] and [supabaseKey] can be found on your Supabase dashboard. 107 | /// 108 | /// You can access none public schema by passing different [schema]. 109 | /// 110 | /// Default headers can be overridden by specifying [headers]. 111 | /// 112 | /// Custom http client can be used by passing [httpClient] parameter. 113 | /// 114 | /// [storageRetryAttempts] specifies how many retry attempts there should be to 115 | /// upload a file to Supabase storage when failed due to network interruption. 116 | /// 117 | /// [realtimeClientOptions] specifies different options you can pass to `RealtimeClient`. 118 | /// 119 | /// Pass an instance of `YAJsonIsolate` to [isolate] to use your own persisted 120 | /// isolate instance. A new instance will be created if [isolate] is omitted. 121 | /// {@macro supabase_client} 122 | SupabaseClient( 123 | this.supabaseUrl, 124 | this.supabaseKey, { 125 | String? schema, 126 | bool autoRefreshToken = true, 127 | Map? headers, 128 | Client? httpClient, 129 | int storageRetryAttempts = 0, 130 | RealtimeClientOptions realtimeClientOptions = const RealtimeClientOptions(), 131 | YAJsonIsolate? isolate, 132 | GotrueAsyncStorage? gotrueAsyncStorage, 133 | AuthFlowType authFlowType = AuthFlowType.implicit, 134 | }) : restUrl = '$supabaseUrl/rest/v1', 135 | realtimeUrl = '$supabaseUrl/realtime/v1'.replaceAll('http', 'ws'), 136 | authUrl = '$supabaseUrl/auth/v1', 137 | storageUrl = '$supabaseUrl/storage/v1', 138 | functionsUrl = RegExp(r'(supabase\.co)|(supabase\.in)') 139 | .hasMatch(supabaseUrl) 140 | ? '${supabaseUrl.split('.')[0]}.functions.${supabaseUrl.split('.')[1]}.${supabaseUrl.split('.')[2]}' 141 | : '$supabaseUrl/functions/v1', 142 | schema = schema ?? 'public', 143 | _headers = { 144 | ...Constants.defaultHeaders, 145 | if (headers != null) ...headers 146 | }, 147 | _httpClient = httpClient, 148 | _storageRetryAttempts = storageRetryAttempts, 149 | _isolate = isolate ?? (YAJsonIsolate()..initialize()) { 150 | auth = _initSupabaseAuthClient( 151 | autoRefreshToken: autoRefreshToken, 152 | headers: _headers, 153 | gotrueAsyncStorage: gotrueAsyncStorage, 154 | authFlowType: authFlowType, 155 | ); 156 | rest = _initRestClient(); 157 | functions = _initFunctionsClient(); 158 | storage = _initStorageClient(); 159 | realtime = _initRealtimeClient( 160 | headers: _headers, 161 | options: realtimeClientOptions, 162 | ); 163 | 164 | _listenForAuthEvents(); 165 | } 166 | 167 | /// Perform a table operation. 168 | SupabaseQueryBuilder from(String table) { 169 | final url = '$restUrl/$table'; 170 | _incrementId++; 171 | return SupabaseQueryBuilder( 172 | url, 173 | realtime, 174 | headers: { 175 | ...rest.headers, 176 | ..._getAuthHeaders(), 177 | }, 178 | schema: schema, 179 | table: table, 180 | httpClient: _httpClient, 181 | incrementId: _incrementId, 182 | isolate: _isolate, 183 | ); 184 | } 185 | 186 | /// Perform a stored procedure call. 187 | PostgrestFilterBuilder rpc( 188 | String fn, { 189 | Map? params, 190 | FetchOptions options = const FetchOptions(), 191 | }) { 192 | rest.headers.addAll({...rest.headers, ..._getAuthHeaders()}); 193 | return rest.rpc(fn, params: params, options: options); 194 | } 195 | 196 | /// Creates a Realtime channel with Broadcast, Presence, and Postgres Changes. 197 | RealtimeChannel channel(String name, 198 | {RealtimeChannelConfig opts = const RealtimeChannelConfig()}) { 199 | return realtime.channel(name, opts); 200 | } 201 | 202 | /// Returns all Realtime channels. 203 | List getChannels() { 204 | return realtime.getChannels(); 205 | } 206 | 207 | /// Unsubscribes and removes Realtime channel from Realtime client. 208 | /// 209 | /// [channel] - The name of the Realtime channel. 210 | Future removeChannel(RealtimeChannel channel) { 211 | return realtime.removeChannel(channel); 212 | } 213 | 214 | /// Unsubscribes and removes all Realtime channels from Realtime client. 215 | Future> removeAllChannels() { 216 | return realtime.removeAllChannels(); 217 | } 218 | 219 | Future dispose() async { 220 | await _authStateSubscription.cancel(); 221 | await _isolate.dispose(); 222 | } 223 | 224 | GoTrueClient _initSupabaseAuthClient({ 225 | bool? autoRefreshToken, 226 | required Map headers, 227 | required GotrueAsyncStorage? gotrueAsyncStorage, 228 | required AuthFlowType authFlowType, 229 | }) { 230 | final authHeaders = {...headers}; 231 | authHeaders['apikey'] = supabaseKey; 232 | authHeaders['Authorization'] = 'Bearer $supabaseKey'; 233 | 234 | return GoTrueClient( 235 | url: authUrl, 236 | headers: authHeaders, 237 | autoRefreshToken: autoRefreshToken, 238 | httpClient: _httpClient, 239 | asyncStorage: gotrueAsyncStorage, 240 | flowType: authFlowType, 241 | ); 242 | } 243 | 244 | PostgrestClient _initRestClient() { 245 | return PostgrestClient( 246 | restUrl, 247 | headers: _getAuthHeaders(), 248 | schema: schema, 249 | httpClient: _httpClient, 250 | isolate: _isolate, 251 | ); 252 | } 253 | 254 | FunctionsClient _initFunctionsClient() { 255 | return FunctionsClient( 256 | functionsUrl, 257 | _getAuthHeaders(), 258 | httpClient: _httpClient, 259 | isolate: _isolate, 260 | ); 261 | } 262 | 263 | SupabaseStorageClient _initStorageClient() { 264 | return SupabaseStorageClient( 265 | storageUrl, 266 | _getAuthHeaders(), 267 | httpClient: _httpClient, 268 | retryAttempts: _storageRetryAttempts, 269 | ); 270 | } 271 | 272 | RealtimeClient _initRealtimeClient({ 273 | required Map headers, 274 | required RealtimeClientOptions options, 275 | }) { 276 | final eventsPerSecond = options.eventsPerSecond; 277 | return RealtimeClient( 278 | realtimeUrl, 279 | params: { 280 | 'apikey': supabaseKey, 281 | if (eventsPerSecond != null) 'eventsPerSecond': '$eventsPerSecond' 282 | }, 283 | headers: headers, 284 | ); 285 | } 286 | 287 | Map _getAuthHeaders() { 288 | final authBearer = auth.currentSession?.accessToken ?? supabaseKey; 289 | final defaultHeaders = { 290 | 'apikey': supabaseKey, 291 | 'Authorization': 'Bearer $authBearer', 292 | }; 293 | final headers = {...defaultHeaders, ..._headers}; 294 | return headers; 295 | } 296 | 297 | void _listenForAuthEvents() { 298 | // ignore: invalid_use_of_internal_member 299 | _authStateSubscription = auth.onAuthStateChangeSync.listen( 300 | (data) { 301 | _handleTokenChanged(data.event, data.session?.accessToken); 302 | }, 303 | onError: (error, stack) {}, 304 | ); 305 | } 306 | 307 | void _handleTokenChanged(AuthChangeEvent event, String? token) { 308 | if (event == AuthChangeEvent.tokenRefreshed || 309 | event == AuthChangeEvent.signedIn && _changedAccessToken != token) { 310 | // Token has changed 311 | _changedAccessToken = token; 312 | rest.setAuth(token); 313 | storage.setAuth(token!); 314 | functions.setAuth(token); 315 | realtime.setAuth(token); 316 | } else if (event == AuthChangeEvent.signedOut || 317 | event == AuthChangeEvent.userDeleted) { 318 | // Token is removed 319 | rest.setAuth(supabaseKey); 320 | storage.setAuth(supabaseKey); 321 | functions.setAuth(supabaseKey); 322 | realtime.setAuth(supabaseKey); 323 | } 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /lib/src/supabase_event_types.dart: -------------------------------------------------------------------------------- 1 | enum SupabaseEventTypes { insert, update, delete, all, broadcast, presence } 2 | 3 | extension SupabaseEventTypesName on SupabaseEventTypes { 4 | String name() { 5 | final name = toString().split('.').last; 6 | if (name == 'all') { 7 | return '*'; 8 | } else { 9 | return name.toUpperCase(); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/src/supabase_query_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart'; 2 | import 'package:supabase/src/supabase_stream_builder.dart'; 3 | import 'package:supabase/supabase.dart'; 4 | import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; 5 | 6 | class SupabaseQueryBuilder extends PostgrestQueryBuilder { 7 | final RealtimeClient _realtime; 8 | final String _schema; 9 | final String _table; 10 | final int _incrementId; 11 | 12 | SupabaseQueryBuilder( 13 | String url, 14 | RealtimeClient realtime, { 15 | Map headers = const {}, 16 | required String schema, 17 | required String table, 18 | Client? httpClient, 19 | required int incrementId, 20 | required YAJsonIsolate isolate, 21 | }) : _realtime = realtime, 22 | _schema = schema, 23 | _table = table, 24 | _incrementId = incrementId, 25 | super( 26 | url, 27 | headers: headers, 28 | schema: schema, 29 | httpClient: httpClient, 30 | isolate: isolate, 31 | ); 32 | 33 | /// Notifies of data at the queried table 34 | /// 35 | /// [primaryKey] list of name of primary key column(s). 36 | /// 37 | /// ```dart 38 | /// supabase.from('chats').stream(primaryKey: ['my_primary_key']).listen(_onChatsReceived); 39 | /// ``` 40 | /// 41 | /// `eq`, `neq`, `lt`, `lte`, `gt` or `gte` and `order`, `limit` filter are available to limit the data being queried. 42 | /// 43 | /// ```dart 44 | /// supabase.from('chats').stream(primaryKey: ['my_primary_key']).eq('room_id','123').order('created_at').limit(20).listen(_onChatsReceived); 45 | /// ``` 46 | SupabaseStreamBuilder stream({required List primaryKey}) { 47 | assert(primaryKey.isNotEmpty, 'Please specify primary key column(s).'); 48 | return SupabaseStreamBuilder( 49 | queryBuilder: this, 50 | realtimeClient: _realtime, 51 | realtimeTopic: '$_schema:$_table:$_incrementId', 52 | schema: _schema, 53 | table: _table, 54 | primaryKey: primaryKey, 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/src/supabase_realtime_error.dart: -------------------------------------------------------------------------------- 1 | class SupabaseRealtimeError extends Error { 2 | /// Creates an Unsubscribe error with the provided [message]. 3 | SupabaseRealtimeError([this.message]); 4 | final Object? message; 5 | 6 | @override 7 | String toString() { 8 | if (message != null) { 9 | return "Unsubscribe failed: ${Error.safeToString(message)}"; 10 | } 11 | return "Unsubscribe failed"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/supabase_stream_builder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:rxdart/rxdart.dart'; 4 | import 'package:supabase/supabase.dart'; 5 | 6 | enum _FilterType { eq, neq, lt, lte, gt, gte, inFilter } 7 | 8 | class _StreamPostgrestFilter { 9 | _StreamPostgrestFilter({ 10 | required this.column, 11 | required this.value, 12 | required this.type, 13 | }); 14 | 15 | /// Column name of the eq filter 16 | final String column; 17 | 18 | /// Value of the eq filter 19 | final dynamic value; 20 | 21 | /// Type of the filer being applied 22 | final _FilterType type; 23 | } 24 | 25 | class _Order { 26 | _Order({ 27 | required this.column, 28 | required this.ascending, 29 | }); 30 | final String column; 31 | final bool ascending; 32 | } 33 | 34 | typedef SupabaseStreamEvent = List>; 35 | 36 | class SupabaseStreamBuilder extends Stream { 37 | final PostgrestQueryBuilder _queryBuilder; 38 | 39 | final RealtimeClient _realtimeClient; 40 | 41 | final String _realtimeTopic; 42 | 43 | RealtimeChannel? _channel; 44 | 45 | final String _schema; 46 | 47 | final String _table; 48 | 49 | /// Used to identify which row has changed 50 | final List _uniqueColumns; 51 | 52 | /// StreamController for `stream()` method. 53 | BehaviorSubject? _streamController; 54 | 55 | /// Contains the combined data of postgrest and realtime to emit as stream. 56 | SupabaseStreamEvent _streamData = []; 57 | 58 | /// `eq` filter used for both postgrest and realtime 59 | _StreamPostgrestFilter? _streamFilter; 60 | 61 | /// Which column to order by and whether it's ascending 62 | _Order? _orderBy; 63 | 64 | /// Count of record to be returned 65 | int? _limit; 66 | 67 | SupabaseStreamBuilder({ 68 | required PostgrestQueryBuilder queryBuilder, 69 | required String realtimeTopic, 70 | required RealtimeClient realtimeClient, 71 | required String schema, 72 | required String table, 73 | required List primaryKey, 74 | }) : _queryBuilder = queryBuilder, 75 | _realtimeTopic = realtimeTopic, 76 | _realtimeClient = realtimeClient, 77 | _schema = schema, 78 | _table = table, 79 | _uniqueColumns = primaryKey; 80 | 81 | /// Filters the results where [column] equals [value]. 82 | /// 83 | /// Only one filter can be applied to `.stream()`. 84 | /// 85 | /// ```dart 86 | /// supabase.from('users').stream(primaryKey: ['id']).eq('name', 'Supabase'); 87 | /// ``` 88 | SupabaseStreamBuilder eq(String column, dynamic value) { 89 | assert( 90 | _streamFilter == null, 91 | 'Only one filter can be applied to `.stream()`', 92 | ); 93 | _streamFilter = _StreamPostgrestFilter( 94 | type: _FilterType.eq, 95 | column: column, 96 | value: value, 97 | ); 98 | return this; 99 | } 100 | 101 | /// Filters the results where [column] does not equal [value]. 102 | /// 103 | /// Only one filter can be applied to `.stream()`. 104 | /// 105 | /// ```dart 106 | /// supabase.from('users').stream(primaryKey: ['id']).neq('name', 'Supabase'); 107 | /// ``` 108 | SupabaseStreamBuilder neq(String column, dynamic value) { 109 | assert( 110 | _streamFilter == null, 111 | 'Only one filter can be applied to `.stream()`', 112 | ); 113 | _streamFilter = _StreamPostgrestFilter( 114 | type: _FilterType.neq, 115 | column: column, 116 | value: value, 117 | ); 118 | return this; 119 | } 120 | 121 | /// Filters the results where [column] is less than [value]. 122 | /// 123 | /// Only one filter can be applied to `.stream()`. 124 | /// 125 | /// ```dart 126 | /// supabase.from('users').stream(primaryKey: ['id']).lt('likes', 100); 127 | /// ``` 128 | SupabaseStreamBuilder lt(String column, dynamic value) { 129 | assert( 130 | _streamFilter == null, 131 | 'Only one filter can be applied to `.stream()`', 132 | ); 133 | _streamFilter = _StreamPostgrestFilter( 134 | type: _FilterType.lt, 135 | column: column, 136 | value: value, 137 | ); 138 | return this; 139 | } 140 | 141 | /// Filters the results where [column] is less than or equal to [value]. 142 | /// 143 | /// Only one filter can be applied to `.stream()`. 144 | /// 145 | /// ```dart 146 | /// supabase.from('users').stream(primaryKey: ['id']).lte('likes', 100); 147 | /// ``` 148 | SupabaseStreamBuilder lte(String column, dynamic value) { 149 | assert( 150 | _streamFilter == null, 151 | 'Only one filter can be applied to `.stream()`', 152 | ); 153 | _streamFilter = _StreamPostgrestFilter( 154 | type: _FilterType.lte, 155 | column: column, 156 | value: value, 157 | ); 158 | return this; 159 | } 160 | 161 | /// Filters the results where [column] is greater than [value]. 162 | /// 163 | /// Only one filter can be applied to `.stream()`. 164 | /// 165 | /// ```dart 166 | /// supabase.from('users').stream(primaryKey: ['id']).gt('likes', '100'); 167 | /// ``` 168 | SupabaseStreamBuilder gt(String column, dynamic value) { 169 | assert( 170 | _streamFilter == null, 171 | 'Only one filter can be applied to `.stream()`', 172 | ); 173 | _streamFilter = _StreamPostgrestFilter( 174 | type: _FilterType.gt, 175 | column: column, 176 | value: value, 177 | ); 178 | return this; 179 | } 180 | 181 | /// Filters the results where [column] is greater than or equal to [value]. 182 | /// 183 | /// Only one filter can be applied to `.stream()`. 184 | /// 185 | /// ```dart 186 | /// supabase.from('users').stream(primaryKey: ['id']).gte('likes', 100); 187 | /// ``` 188 | SupabaseStreamBuilder gte(String column, dynamic value) { 189 | assert( 190 | _streamFilter == null, 191 | 'Only one filter can be applied to `.stream()`', 192 | ); 193 | _streamFilter = _StreamPostgrestFilter( 194 | type: _FilterType.gte, 195 | column: column, 196 | value: value, 197 | ); 198 | return this; 199 | } 200 | 201 | /// Filters the results where [column] is included in [value]. 202 | /// 203 | /// Only one filter can be applied to `.stream()`. 204 | /// 205 | /// ```dart 206 | /// supabase.from('users').stream(primaryKey: ['id']).inFilter('name', ['Andy', 'Amy', 'Terry']); 207 | /// ``` 208 | SupabaseStreamBuilder inFilter(String column, List values) { 209 | assert( 210 | _streamFilter == null, 211 | 'Only one filter can be applied to `.stream()`', 212 | ); 213 | _streamFilter = _StreamPostgrestFilter( 214 | type: _FilterType.inFilter, 215 | column: column, 216 | value: values, 217 | ); 218 | return this; 219 | } 220 | 221 | /// Orders the result with the specified [column]. 222 | /// 223 | /// When `ascending` value is true, the result will be in ascending order. 224 | /// 225 | /// ```dart 226 | /// supabase.from('users').stream(primaryKey: ['id']).order('username', ascending: false); 227 | /// ``` 228 | SupabaseStreamBuilder order(String column, {bool ascending = false}) { 229 | _orderBy = _Order(column: column, ascending: ascending); 230 | return this; 231 | } 232 | 233 | /// Limits the result with the specified `count`. 234 | /// 235 | /// ```dart 236 | /// supabase.from('users').stream(primaryKey: ['id']).limit(10); 237 | /// ``` 238 | SupabaseStreamBuilder limit(int count) { 239 | _limit = count; 240 | return this; 241 | } 242 | 243 | @Deprecated('Directly listen without execute instead. Deprecated in 1.0.0') 244 | Stream execute() { 245 | _setupStream(); 246 | return _streamController!.stream; 247 | } 248 | 249 | @override 250 | StreamSubscription listen( 251 | void Function(SupabaseStreamEvent event)? onData, { 252 | Function? onError, 253 | void Function()? onDone, 254 | bool? cancelOnError, 255 | }) { 256 | _setupStream(); 257 | return _streamController!.stream.listen( 258 | onData, 259 | onError: onError, 260 | onDone: onDone, 261 | cancelOnError: cancelOnError, 262 | ); 263 | } 264 | 265 | /// Sets up the stream controller and calls the method to get data as necessary 266 | void _setupStream() { 267 | _streamController ??= BehaviorSubject( 268 | onListen: () { 269 | _getStreamData(); 270 | }, 271 | onCancel: () { 272 | _channel?.unsubscribe(); 273 | _streamController?.close(); 274 | _streamController = null; 275 | }, 276 | ); 277 | } 278 | 279 | Future _getStreamData() async { 280 | final currentStreamFilter = _streamFilter; 281 | _streamData = []; 282 | String? realtimeFilter; 283 | if (currentStreamFilter != null) { 284 | if (currentStreamFilter.type == _FilterType.inFilter) { 285 | final value = currentStreamFilter.value; 286 | if (value is List) { 287 | realtimeFilter = 288 | '${currentStreamFilter.column}=in.(${value.map((s) => '"$s"').join(',')})'; 289 | } else { 290 | realtimeFilter = 291 | '${currentStreamFilter.column}=in.(${value.join(',')})'; 292 | } 293 | } else { 294 | realtimeFilter = 295 | '${currentStreamFilter.column}=${currentStreamFilter.type.name}.${currentStreamFilter.value}'; 296 | } 297 | } 298 | 299 | _channel = _realtimeClient.channel(_realtimeTopic); 300 | _channel!.on( 301 | RealtimeListenTypes.postgresChanges, 302 | ChannelFilter( 303 | event: 'INSERT', 304 | schema: _schema, 305 | table: _table, 306 | filter: realtimeFilter, 307 | ), (payload, [ref]) { 308 | final newRecord = Map.from(payload['new']!); 309 | _streamData.add(newRecord); 310 | _addStream(); 311 | }).on( 312 | RealtimeListenTypes.postgresChanges, 313 | ChannelFilter( 314 | event: 'UPDATE', 315 | schema: _schema, 316 | table: _table, 317 | filter: realtimeFilter, 318 | ), (payload, [ref]) { 319 | final updatedIndex = _streamData.indexWhere( 320 | (element) => _isTargetRecord(record: element, payload: payload), 321 | ); 322 | 323 | final updatedRecord = Map.from(payload['new']!); 324 | if (updatedIndex >= 0) { 325 | _streamData[updatedIndex] = updatedRecord; 326 | } else { 327 | _streamData.add(updatedRecord); 328 | } 329 | _addStream(); 330 | }).on( 331 | RealtimeListenTypes.postgresChanges, 332 | ChannelFilter( 333 | event: 'DELETE', 334 | schema: _schema, 335 | table: _table, 336 | filter: realtimeFilter, 337 | ), (payload, [ref]) { 338 | final deletedIndex = _streamData.indexWhere( 339 | (element) => _isTargetRecord(record: element, payload: payload), 340 | ); 341 | if (deletedIndex >= 0) { 342 | /// Delete the data from in memory cache if it was found 343 | _streamData.removeAt(deletedIndex); 344 | _addStream(); 345 | } 346 | }).subscribe(); 347 | 348 | PostgrestFilterBuilder query = _queryBuilder.select(); 349 | if (_streamFilter != null) { 350 | switch (_streamFilter!.type) { 351 | case _FilterType.eq: 352 | query = query.eq(_streamFilter!.column, _streamFilter!.value); 353 | break; 354 | case _FilterType.neq: 355 | query = query.neq(_streamFilter!.column, _streamFilter!.value); 356 | break; 357 | case _FilterType.lt: 358 | query = query.lt(_streamFilter!.column, _streamFilter!.value); 359 | break; 360 | case _FilterType.lte: 361 | query = query.lte(_streamFilter!.column, _streamFilter!.value); 362 | break; 363 | case _FilterType.gt: 364 | query = query.gt(_streamFilter!.column, _streamFilter!.value); 365 | break; 366 | case _FilterType.gte: 367 | query = query.gte(_streamFilter!.column, _streamFilter!.value); 368 | break; 369 | case _FilterType.inFilter: 370 | query = query.in_(_streamFilter!.column, _streamFilter!.value); 371 | break; 372 | } 373 | } 374 | PostgrestTransformBuilder? transformQuery; 375 | if (_orderBy != null) { 376 | transformQuery = 377 | query.order(_orderBy!.column, ascending: _orderBy!.ascending); 378 | } 379 | if (_limit != null) { 380 | transformQuery = (transformQuery ?? query).limit(_limit!); 381 | } 382 | 383 | try { 384 | final data = await (transformQuery ?? query); 385 | final rows = SupabaseStreamEvent.from(data as List); 386 | _streamData.addAll(rows); 387 | _addStream(); 388 | } catch (error, stackTrace) { 389 | _addException(error, stackTrace); 390 | } 391 | } 392 | 393 | bool _isTargetRecord({ 394 | required Map record, 395 | required Map payload, 396 | }) { 397 | late final Map targetRecord; 398 | if (payload['eventType'] == 'UPDATE') { 399 | targetRecord = payload['new']!; 400 | } else if (payload['eventType'] == 'DELETE') { 401 | targetRecord = payload['old']!; 402 | } 403 | return _uniqueColumns 404 | .every((column) => record[column] == targetRecord[column]); 405 | } 406 | 407 | void _sortData() { 408 | final orderModifier = _orderBy!.ascending ? 1 : -1; 409 | _streamData.sort((a, b) { 410 | if (a[_orderBy!.column] is String && b[_orderBy!.column] is String) { 411 | return orderModifier * 412 | (a[_orderBy!.column] as String) 413 | .compareTo(b[_orderBy!.column] as String); 414 | } else if (a[_orderBy!.column] is int && b[_orderBy!.column] is int) { 415 | return orderModifier * 416 | (a[_orderBy!.column] as int).compareTo(b[_orderBy!.column] as int); 417 | } else { 418 | return 0; 419 | } 420 | }); 421 | } 422 | 423 | /// Will add new data to the stream if streamController is not closed 424 | void _addStream() { 425 | if (_orderBy != null) { 426 | _sortData(); 427 | } 428 | if (!(_streamController?.isClosed ?? true)) { 429 | final emitData = 430 | (_limit != null ? _streamData.take(_limit!) : _streamData).toList(); 431 | _streamController!.add(emitData); 432 | } 433 | } 434 | 435 | /// Will add error to the stream if streamController is not closed 436 | void _addException(Object error, [StackTrace? stackTrace]) { 437 | if (!(_streamController?.isClosed ?? true)) { 438 | _streamController?.addError(error, stackTrace ?? StackTrace.current); 439 | } 440 | } 441 | 442 | @override 443 | bool get isBroadcast => true; 444 | 445 | @override 446 | Stream asyncMap( 447 | FutureOr Function(SupabaseStreamEvent event) convert) { 448 | // Copied from [Stream.asyncMap] 449 | 450 | final controller = BehaviorSubject(); 451 | 452 | controller.onListen = () { 453 | StreamSubscription subscription = listen(null, 454 | onError: controller.addError, // Avoid Zone error replacement. 455 | onDone: controller.close); 456 | FutureOr add(E value) { 457 | controller.add(value); 458 | } 459 | 460 | final addError = controller.addError; 461 | final resume = subscription.resume; 462 | subscription.onData((SupabaseStreamEvent event) { 463 | FutureOr newValue; 464 | try { 465 | newValue = convert(event); 466 | } catch (e, s) { 467 | controller.addError(e, s); 468 | return; 469 | } 470 | if (newValue is Future) { 471 | subscription.pause(); 472 | newValue.then(add, onError: addError).whenComplete(resume); 473 | } else { 474 | controller.add(newValue as dynamic); 475 | } 476 | }); 477 | controller.onCancel = subscription.cancel; 478 | if (!isBroadcast) { 479 | controller 480 | ..onPause = subscription.pause 481 | ..onResume = resume; 482 | } 483 | }; 484 | return controller.stream; 485 | } 486 | 487 | @override 488 | Stream asyncExpand( 489 | Stream? Function(SupabaseStreamEvent event) convert) { 490 | //Copied from [Stream.asyncExpand] 491 | final controller = BehaviorSubject(); 492 | controller.onListen = () { 493 | StreamSubscription subscription = listen(null, 494 | onError: controller.addError, // Avoid Zone error replacement. 495 | onDone: controller.close); 496 | subscription.onData((SupabaseStreamEvent event) { 497 | Stream? newStream; 498 | try { 499 | newStream = convert(event); 500 | } catch (e, s) { 501 | controller.addError(e, s); 502 | return; 503 | } 504 | if (newStream != null) { 505 | subscription.pause(); 506 | controller.addStream(newStream).whenComplete(subscription.resume); 507 | } 508 | }); 509 | controller.onCancel = subscription.cancel; 510 | if (!isBroadcast) { 511 | controller 512 | ..onPause = subscription.pause 513 | ..onResume = subscription.resume; 514 | } 515 | }; 516 | return controller.stream; 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /lib/src/version.dart: -------------------------------------------------------------------------------- 1 | const version = '1.8.0'; 2 | -------------------------------------------------------------------------------- /lib/supabase.dart: -------------------------------------------------------------------------------- 1 | /// A dart client for Supabase. It supports database query, authenticate users 2 | /// and listen for realtime changes. This client makes it simple for developers 3 | /// to build secure and scalable products. 4 | /// 5 | library supabase; 6 | 7 | export 'package:functions_client/functions_client.dart'; 8 | export 'package:gotrue/gotrue.dart'; 9 | export 'package:postgrest/postgrest.dart'; 10 | export 'package:realtime_client/realtime_client.dart'; 11 | export 'package:storage_client/storage_client.dart'; 12 | 13 | export 'src/auth_user.dart'; 14 | export 'src/realtime_client_options.dart'; 15 | export 'src/remove_subscription_result.dart'; 16 | export 'src/supabase_client.dart'; 17 | export 'src/supabase_event_types.dart'; 18 | export 'src/supabase_query_builder.dart'; 19 | export 'src/supabase_realtime_error.dart'; 20 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: supabase 2 | description: A dart client for Supabase. This client makes it simple for developers to build secure and scalable products. 3 | version: 1.8.0 4 | homepage: 'https://supabase.io' 5 | repository: 'https://github.com/supabase/supabase-dart' 6 | 7 | environment: 8 | sdk: '>=2.15.0 <3.0.0' 9 | 10 | dependencies: 11 | functions_client: ^1.2.0 12 | gotrue: ^1.7.0 13 | http: ^0.13.4 14 | postgrest: ^1.2.3 15 | realtime_client: ^1.0.2 16 | storage_client: ^1.2.3 17 | rxdart: ^0.27.5 18 | yet_another_json_isolate: ^1.0.1 19 | 20 | dev_dependencies: 21 | lints: ^1.0.1 22 | test: ^1.17.9 23 | web_socket_channel: ^2.2.0 24 | -------------------------------------------------------------------------------- /test/client_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:supabase/supabase.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Standard Header', () { 6 | const supabaseUrl = ''; 7 | const supabaseKey = ''; 8 | late SupabaseClient client; 9 | 10 | setUp(() { 11 | client = SupabaseClient(supabaseUrl, supabaseKey); 12 | }); 13 | 14 | tearDown(() async { 15 | await client.dispose(); 16 | }); 17 | 18 | test('X-Client-Info header is set properly on realtime', () { 19 | final xClientHeaderBeforeSlash = 20 | client.realtime.headers['X-Client-Info']!.split('/').first; 21 | expect(xClientHeaderBeforeSlash, 'supabase-dart'); 22 | }); 23 | 24 | test('X-Client-Info header is set properly on storage', () { 25 | final xClientHeaderBeforeSlash = 26 | client.storage.headers['X-Client-Info']!.split('/').first; 27 | expect(xClientHeaderBeforeSlash, 'supabase-dart'); 28 | }); 29 | }); 30 | 31 | group('Custom Header', () { 32 | const supabaseUrl = ''; 33 | const supabaseKey = ''; 34 | late SupabaseClient client; 35 | 36 | setUp(() { 37 | client = SupabaseClient( 38 | supabaseUrl, 39 | supabaseKey, 40 | headers: { 41 | 'X-Client-Info': 'supabase-flutter/0.0.0', 42 | }, 43 | ); 44 | }); 45 | 46 | test('X-Client-Info header is set properly on realtime', () { 47 | final xClientInfoHeader = client.realtime.headers['X-Client-Info']; 48 | expect(xClientInfoHeader, 'supabase-flutter/0.0.0'); 49 | }); 50 | 51 | test('X-Client-Info header is set properly on storage', () { 52 | final xClientInfoHeader = client.storage.headers['X-Client-Info']; 53 | expect(xClientInfoHeader, 'supabase-flutter/0.0.0'); 54 | }); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /test/mock_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:supabase/supabase.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | void main() { 9 | late SupabaseClient client; 10 | late SupabaseClient customHeadersClient; 11 | late HttpServer mockServer; 12 | late String apiKey; 13 | final Set listeners = {}; 14 | const customApiKey = 'customApiKey'; 15 | const customHeaders = {'customfield': 'customvalue', 'apikey': customApiKey}; 16 | WebSocket? webSocket; 17 | bool hasListener = false; 18 | StreamSubscription? listener; 19 | 20 | /// `testFilter` is used to test incoming realtime filter. The value should match the realtime filter set by the library. 21 | Future handleRequests( 22 | HttpServer server, { 23 | String? expectedFilter, 24 | }) async { 25 | await for (final HttpRequest request in server) { 26 | final headers = request.headers; 27 | if (headers.value('X-Client-Info') != 'supabase-flutter/0.0.0') { 28 | throw 'Proper header not set'; 29 | } 30 | final url = request.uri.toString(); 31 | if (url.startsWith("/rest")) { 32 | final foundApiKey = headers.value('apikey'); 33 | expect(foundApiKey, apiKey); 34 | if (foundApiKey == customApiKey) { 35 | expect(headers.value('customfield'), 'customvalue'); 36 | } 37 | 38 | // Check that rest api contains the correct filter in the URL 39 | if (expectedFilter != null) { 40 | expect(url.contains(expectedFilter), isTrue); 41 | } 42 | } 43 | if (url == '/rest/v1/todos?select=task%2Cstatus') { 44 | final jsonString = jsonEncode([ 45 | {'task': 'task 1', 'status': true}, 46 | {'task': 'task 2', 'status': false} 47 | ]); 48 | request.response 49 | ..statusCode = HttpStatus.ok 50 | ..headers.contentType = ContentType.json 51 | ..write(jsonString) 52 | ..close(); 53 | } else if (url == '/rest/v1/todos?select=%2A' || 54 | url == '/rest/v1/rpc/todos?select=%2A') { 55 | final jsonString = jsonEncode([ 56 | {'id': 1, 'task': 'task 1', 'status': true}, 57 | {'id': 2, 'task': 'task 2', 'status': false} 58 | ]); 59 | request.response 60 | ..statusCode = HttpStatus.ok 61 | ..headers.contentType = ContentType.json 62 | ..write(jsonString) 63 | ..close(); 64 | } else if (url == '/rest/v1/todos?select=%2A&status=eq.true') { 65 | final jsonString = jsonEncode([ 66 | {'id': 1, 'task': 'task 1', 'status': true}, 67 | ]); 68 | request.response 69 | ..statusCode = HttpStatus.ok 70 | ..headers.contentType = ContentType.json 71 | ..write(jsonString) 72 | ..close(); 73 | } else if (url == '/rest/v1/todos?select=%2A&order=id.desc.nullslast') { 74 | final jsonString = jsonEncode([ 75 | {'id': 2, 'task': 'task 2', 'status': false}, 76 | {'id': 1, 'task': 'task 1', 'status': true}, 77 | ]); 78 | request.response 79 | ..statusCode = HttpStatus.ok 80 | ..headers.contentType = ContentType.json 81 | ..write(jsonString) 82 | ..close(); 83 | } else if (url == 84 | '/rest/v1/todos?select=%2A&order=id.desc.nullslast&limit=2') { 85 | final jsonString = jsonEncode([ 86 | {'id': 2, 'task': 'task 2', 'status': false}, 87 | {'id': 1, 'task': 'task 1', 'status': true}, 88 | ]); 89 | request.response 90 | ..statusCode = HttpStatus.ok 91 | ..headers.contentType = ContentType.json 92 | ..write(jsonString) 93 | ..close(); 94 | } else if (url.contains('rest')) { 95 | // Just return an empty string as dummy data if any other rest request 96 | request.response 97 | ..statusCode = HttpStatus.ok 98 | ..headers.contentType = ContentType.json 99 | ..write('[]') 100 | ..close(); 101 | } else if (url.contains('realtime')) { 102 | webSocket = await WebSocketTransformer.upgrade(request); 103 | if (hasListener) { 104 | return; 105 | } 106 | hasListener = true; 107 | listener = webSocket!.listen((request) async { 108 | /// `filter` might be there or not depending on whether is a filter set 109 | /// to the realtime subscription, so include the filter if the request 110 | /// includes a filter. 111 | final requestJson = jsonDecode(request); 112 | final topic = requestJson['topic']; 113 | final ref = requestJson["ref"]; 114 | 115 | if (requestJson["event"] == "phx_leave") { 116 | listeners.remove(topic); 117 | return; 118 | } 119 | if (listeners.contains(topic)) { 120 | return; 121 | } 122 | listeners.add(topic); 123 | 124 | final String? realtimeFilter = requestJson['payload']['config'] 125 | ['postgres_changes'] 126 | .first['filter']; 127 | 128 | if (expectedFilter != null) { 129 | expect(realtimeFilter, expectedFilter); 130 | } 131 | 132 | final replyString = jsonEncode({ 133 | 'event': 'phx_reply', 134 | 'payload': { 135 | 'response': { 136 | 'postgres_changes': [ 137 | { 138 | 'id': 77086988, 139 | 'event': 'INSERT', 140 | 'schema': 'public', 141 | 'table': 'todos', 142 | if (realtimeFilter != null) 'filter': realtimeFilter, 143 | }, 144 | { 145 | 'id': 25993878, 146 | 'event': 'UPDATE', 147 | 'schema': 'public', 148 | 'table': 'todos', 149 | if (realtimeFilter != null) 'filter': realtimeFilter, 150 | }, 151 | { 152 | 'id': 48673474, 153 | 'event': 'DELETE', 154 | 'schema': 'public', 155 | 'table': 'todos', 156 | if (realtimeFilter != null) 'filter': realtimeFilter, 157 | } 158 | ] 159 | }, 160 | 'status': 'ok' 161 | }, 162 | 'ref': ref, 163 | 'topic': topic 164 | }); 165 | webSocket!.add(replyString); 166 | 167 | // Send an insert event 168 | await Future.delayed(Duration(milliseconds: 10)); 169 | final insertString = jsonEncode({ 170 | 'topic': topic, 171 | 'event': 'postgres_changes', 172 | 'ref': null, 173 | 'payload': { 174 | 'ids': [77086988], 175 | 'data': { 176 | 'commit_timestamp': '2021-08-01T08:00:20Z', 177 | 'record': {'id': 3, 'task': 'task 3', 'status': 't'}, 178 | 'schema': 'public', 179 | 'table': 'todos', 180 | 'type': 'INSERT', 181 | if (realtimeFilter != null) 'filter': realtimeFilter, 182 | 'columns': [ 183 | { 184 | 'name': 'id', 185 | 'type': 'int4', 186 | 'type_modifier': 4294967295, 187 | }, 188 | { 189 | 'name': 'task', 190 | 'type': 'text', 191 | 'type_modifier': 4294967295, 192 | }, 193 | { 194 | 'name': 'status', 195 | 'type': 'bool', 196 | 'type_modifier': 4294967295, 197 | }, 198 | ], 199 | }, 200 | }, 201 | }); 202 | webSocket!.add(insertString); 203 | 204 | // Send an update event for id = 2 205 | await Future.delayed(Duration(milliseconds: 10)); 206 | final updateString = jsonEncode({ 207 | 'topic': topic, 208 | 'ref': null, 209 | 'event': 'postgres_changes', 210 | 'payload': { 211 | 'ids': [25993878], 212 | 'data': { 213 | 'columns': [ 214 | {'name': 'id', 'type': 'int4', 'type_modifier': 4294967295}, 215 | {'name': 'task', 'type': 'text', 'type_modifier': 4294967295}, 216 | { 217 | 'name': 'status', 218 | 'type': 'bool', 219 | 'type_modifier': 4294967295 220 | }, 221 | ], 222 | 'commit_timestamp': '2021-08-01T08:00:30Z', 223 | 'errors': null, 224 | 'old_record': {'id': 2}, 225 | 'record': {'id': 2, 'task': 'task 2 updated', 'status': 'f'}, 226 | 'schema': 'public', 227 | 'table': 'todos', 228 | 'type': 'UPDATE', 229 | if (realtimeFilter != null) 'filter': realtimeFilter, 230 | }, 231 | }, 232 | }); 233 | webSocket!.add(updateString); 234 | 235 | // Send delete event for id=2 236 | await Future.delayed(Duration(milliseconds: 10)); 237 | final deleteString = jsonEncode({ 238 | 'ref': null, 239 | 'topic': topic, 240 | 'event': 'postgres_changes', 241 | 'payload': { 242 | 'data': { 243 | 'columns': [ 244 | {'name': 'id', 'type': 'int4', 'type_modifier': 4294967295}, 245 | {'name': 'task', 'type': 'text', 'type_modifier': 4294967295}, 246 | { 247 | 'name': 'status', 248 | 'type': 'bool', 249 | 'type_modifier': 4294967295 250 | }, 251 | ], 252 | 'commit_timestamp': '2022-09-14T02:12:52Z', 253 | 'errors': null, 254 | 'old_record': {'id': 2}, 255 | 'schema': 'public', 256 | 'table': 'todos', 257 | 'type': 'DELETE', 258 | if (realtimeFilter != null) 'filter': realtimeFilter, 259 | }, 260 | 'ids': [48673474] 261 | }, 262 | }); 263 | webSocket!.add(deleteString); 264 | 265 | /// Send an update event for id = 4 266 | /// Record with id = 4 did not exist in the initial data fetch, 267 | /// so the SDK should insert the record in the in memory cache 268 | await Future.delayed(Duration(milliseconds: 10)); 269 | final updateId4 = jsonEncode({ 270 | 'topic': topic, 271 | 'ref': null, 272 | 'event': 'postgres_changes', 273 | 'payload': { 274 | 'ids': [25993878], 275 | 'data': { 276 | 'columns': [ 277 | {'name': 'id', 'type': 'int4', 'type_modifier': 4294967295}, 278 | {'name': 'task', 'type': 'text', 'type_modifier': 4294967295}, 279 | { 280 | 'name': 'status', 281 | 'type': 'bool', 282 | 'type_modifier': 4294967295 283 | }, 284 | ], 285 | 'commit_timestamp': '2021-08-01T08:00:30Z', 286 | 'errors': null, 287 | 'old_record': {'id': 4}, 288 | 'record': {'id': 4, 'task': 'task 4', 'status': 't'}, 289 | 'schema': 'public', 290 | 'table': 'todos', 291 | 'type': 'UPDATE', 292 | if (realtimeFilter != null) 'filter': realtimeFilter, 293 | }, 294 | }, 295 | }); 296 | webSocket!.add(updateId4); 297 | 298 | // Send delete event for id=5 299 | /// Should be ignored by the SDK 300 | await Future.delayed(Duration(milliseconds: 10)); 301 | final ignoredDeleteString = jsonEncode({ 302 | 'ref': null, 303 | 'topic': topic, 304 | 'event': 'postgres_changes', 305 | 'payload': { 306 | 'data': { 307 | 'columns': [ 308 | {'name': 'id', 'type': 'int4', 'type_modifier': 4294967295}, 309 | {'name': 'task', 'type': 'text', 'type_modifier': 4294967295}, 310 | { 311 | 'name': 'status', 312 | 'type': 'bool', 313 | 'type_modifier': 4294967295 314 | }, 315 | ], 316 | 'commit_timestamp': '2022-09-14T02:12:52Z', 317 | 'errors': null, 318 | 'old_record': {'id': 5}, 319 | 'schema': 'public', 320 | 'table': 'todos', 321 | 'type': 'DELETE', 322 | if (realtimeFilter != null) 'filter': realtimeFilter, 323 | }, 324 | 'ids': [48673474] 325 | }, 326 | }); 327 | webSocket!.add(ignoredDeleteString); 328 | }); 329 | } else { 330 | request.response 331 | ..statusCode = HttpStatus.ok 332 | ..close(); 333 | } 334 | } 335 | } 336 | 337 | setUp(() async { 338 | apiKey = 'supabaseKey'; 339 | mockServer = await HttpServer.bind('localhost', 0); 340 | client = SupabaseClient( 341 | 'http://${mockServer.address.host}:${mockServer.port}', 342 | apiKey, 343 | headers: { 344 | 'X-Client-Info': 'supabase-flutter/0.0.0', 345 | }, 346 | ); 347 | customHeadersClient = SupabaseClient( 348 | 'http://${mockServer.address.host}:${mockServer.port}', 349 | apiKey, 350 | headers: {'X-Client-Info': 'supabase-flutter/0.0.0', ...customHeaders}, 351 | ); 352 | hasListener = false; 353 | }); 354 | 355 | tearDown(() async { 356 | listeners.clear(); 357 | 358 | await client.dispose(); 359 | await customHeadersClient.dispose(); 360 | 361 | //Manually disconnect the socket channel to avoid automatic retrying to reconnect. This caused failing in later executed tests. 362 | client.realtime.disconnect(); 363 | customHeadersClient.realtime.disconnect(); 364 | 365 | // Wait for the realtime updates to come through 366 | await Future.delayed(Duration(milliseconds: 100)); 367 | 368 | await webSocket?.close(); 369 | 370 | await listener?.cancel(); 371 | 372 | await mockServer.close(); 373 | }); 374 | 375 | group('basic test', () { 376 | setUp(() async { 377 | handleRequests(mockServer); 378 | }); 379 | 380 | test('test mock server', () async { 381 | final data = await client.from('todos').select('task, status'); 382 | expect((data as List).length, 2); 383 | }); 384 | 385 | group('Basic client test', () { 386 | test('Postgrest calls the correct endpoint', () async { 387 | final data = await client.from('todos').select(); 388 | expect(data, [ 389 | {'id': 1, 'task': 'task 1', 'status': true}, 390 | {'id': 2, 'task': 'task 2', 'status': false} 391 | ]); 392 | }); 393 | 394 | test('Postgrest calls the correct endpoint with custom headers', 395 | () async { 396 | apiKey = customApiKey; 397 | final data = await customHeadersClient.from('todos').select(); 398 | expect(data, [ 399 | {'id': 1, 'task': 'task 1', 'status': true}, 400 | {'id': 2, 'task': 'task 2', 'status': false} 401 | ]); 402 | }); 403 | }); 404 | 405 | group('stream()', () { 406 | test("listen, cancel and listen again", () async { 407 | final stream = client.from('todos').stream(primaryKey: ['id']); 408 | final sub = stream.listen(expectAsync1((event) {}, count: 5)); 409 | await Future.delayed(Duration(seconds: 1)); 410 | 411 | await sub.cancel(); 412 | await Future.delayed(Duration(seconds: 1)); 413 | 414 | stream.listen(expectAsync1((event) {}, count: 5)); 415 | }); 416 | 417 | test("can listen twice at the same time", () async { 418 | final stream = client.from('todos').stream(primaryKey: ['id']); 419 | stream.listen(expectAsync1((event) {}, count: 5)); 420 | stream.listen(expectAsync1((event) {}, count: 5)); 421 | 422 | // All realtime events are done emitting, so should receive the currnet data 423 | }); 424 | 425 | test("Create two stream to same table", () async { 426 | final stream1 = client.from('todos').stream(primaryKey: ['id']); 427 | final stream2 = client.from('todos').stream(primaryKey: ['id']); 428 | stream1.listen(expectAsync1((event) {}, count: 5)); 429 | 430 | stream2.listen(expectAsync1((event) {}, count: 5)); 431 | }); 432 | 433 | test("stream should emit the last emitted data when listened to", 434 | () async { 435 | final stream = client.from('todos').stream(primaryKey: ['id']); 436 | stream.listen(expectAsync1((event) {}, count: 5)); 437 | 438 | await Future.delayed(Duration(seconds: 3)); 439 | 440 | // All realtime events are done emitting, so should receive the currnet data 441 | stream.listen(expectAsync1((event) {}, count: 1)); 442 | }); 443 | test('emits data', () { 444 | final stream = client.from('todos').stream(primaryKey: ['id']); 445 | expect( 446 | stream, 447 | emitsInOrder([ 448 | containsAllInOrder([ 449 | {'id': 1, 'task': 'task 1', 'status': true}, 450 | {'id': 2, 'task': 'task 2', 'status': false} 451 | ]), 452 | containsAllInOrder([ 453 | {'id': 1, 'task': 'task 1', 'status': true}, 454 | {'id': 2, 'task': 'task 2', 'status': false}, 455 | {'id': 3, 'task': 'task 3', 'status': true}, 456 | ]), 457 | containsAllInOrder([ 458 | {'id': 1, 'task': 'task 1', 'status': true}, 459 | {'id': 2, 'task': 'task 2 updated', 'status': false}, 460 | {'id': 3, 'task': 'task 3', 'status': true}, 461 | ]), 462 | containsAllInOrder([ 463 | {'id': 1, 'task': 'task 1', 'status': true}, 464 | {'id': 3, 'task': 'task 3', 'status': true}, 465 | ]), 466 | containsAllInOrder([ 467 | {'id': 1, 'task': 'task 1', 'status': true}, 468 | {'id': 3, 'task': 'task 3', 'status': true}, 469 | {'id': 4, 'task': 'task 4', 'status': true}, 470 | ]), 471 | ]), 472 | ); 473 | }); 474 | 475 | test('emits data with asyncMap', () { 476 | final stream = client.from('todos').stream( 477 | primaryKey: ['id']).asyncMap((event) => Future.value([event])); 478 | expect( 479 | stream, 480 | emitsInOrder([ 481 | containsAllInOrder([ 482 | [ 483 | {'id': 1, 'task': 'task 1', 'status': true}, 484 | {'id': 2, 'task': 'task 2', 'status': false} 485 | ] 486 | ]), 487 | containsAllInOrder([ 488 | [ 489 | {'id': 1, 'task': 'task 1', 'status': true}, 490 | {'id': 2, 'task': 'task 2', 'status': false}, 491 | {'id': 3, 'task': 'task 3', 'status': true}, 492 | ] 493 | ]), 494 | containsAllInOrder([ 495 | [ 496 | {'id': 1, 'task': 'task 1', 'status': true}, 497 | {'id': 2, 'task': 'task 2 updated', 'status': false}, 498 | {'id': 3, 'task': 'task 3', 'status': true}, 499 | ] 500 | ]), 501 | containsAllInOrder([ 502 | [ 503 | {'id': 1, 'task': 'task 1', 'status': true}, 504 | {'id': 3, 'task': 'task 3', 'status': true}, 505 | ] 506 | ]), 507 | ]), 508 | ); 509 | }); 510 | 511 | test("can listen twice at the same time with asyncMap", () async { 512 | final stream = client 513 | .from('todos') 514 | .stream(primaryKey: ['id']).asyncMap((event) => event); 515 | stream.listen(expectAsync1((event) { 516 | print(event); 517 | }, count: 5)); 518 | 519 | await Future.delayed(Duration(seconds: 3)); 520 | 521 | // All realtime events are done emitting, so should receive the currnet data 522 | stream.listen(expectAsync1((event) { 523 | print('called'); 524 | }, count: 1)); 525 | }); 526 | 527 | test('emits data with custom headers', () { 528 | apiKey = customApiKey; 529 | final stream = 530 | customHeadersClient.from('todos').stream(primaryKey: ['id']); 531 | expect( 532 | stream, 533 | emitsInOrder([ 534 | containsAllInOrder([ 535 | {'id': 1, 'task': 'task 1', 'status': true}, 536 | {'id': 2, 'task': 'task 2', 'status': false} 537 | ]), 538 | containsAllInOrder([ 539 | {'id': 1, 'task': 'task 1', 'status': true}, 540 | {'id': 2, 'task': 'task 2', 'status': false}, 541 | {'id': 3, 'task': 'task 3', 'status': true}, 542 | ]), 543 | ]), 544 | ); 545 | }); 546 | 547 | test('with order', () { 548 | final stream = 549 | client.from('todos').stream(primaryKey: ['id']).order('id'); 550 | expect( 551 | stream, 552 | emitsInOrder([ 553 | containsAllInOrder([ 554 | {'id': 2, 'task': 'task 2', 'status': false}, 555 | {'id': 1, 'task': 'task 1', 'status': true}, 556 | ]), 557 | containsAllInOrder([ 558 | {'id': 3, 'task': 'task 3', 'status': true}, 559 | {'id': 2, 'task': 'task 2', 'status': false}, 560 | {'id': 1, 'task': 'task 1', 'status': true}, 561 | ]), 562 | containsAllInOrder([ 563 | {'id': 3, 'task': 'task 3', 'status': true}, 564 | {'id': 2, 'task': 'task 2 updated', 'status': false}, 565 | {'id': 1, 'task': 'task 1', 'status': true}, 566 | ]), 567 | containsAllInOrder([ 568 | {'id': 3, 'task': 'task 3', 'status': true}, 569 | {'id': 1, 'task': 'task 1', 'status': true}, 570 | ]), 571 | ]), 572 | ); 573 | }); 574 | 575 | test('with limit', () { 576 | final stream = client 577 | .from('todos') 578 | .stream(primaryKey: ['id']) 579 | .order('id') 580 | .limit(2); 581 | expect( 582 | stream, 583 | emitsInOrder([ 584 | containsAllInOrder([ 585 | {'id': 2, 'task': 'task 2', 'status': false}, 586 | {'id': 1, 'task': 'task 1', 'status': true}, 587 | ]), 588 | containsAllInOrder([ 589 | {'id': 3, 'task': 'task 3', 'status': true}, 590 | {'id': 2, 'task': 'task 2', 'status': false}, 591 | ]), 592 | containsAllInOrder([ 593 | {'id': 3, 'task': 'task 3', 'status': true}, 594 | {'id': 2, 'task': 'task 2 updated', 'status': false}, 595 | ]), 596 | containsAllInOrder([ 597 | {'id': 3, 'task': 'task 3', 'status': true}, 598 | {'id': 1, 'task': 'task 1', 'status': true}, 599 | ]), 600 | ]), 601 | ); 602 | }); 603 | }); 604 | 605 | group("rpc", () { 606 | test("rpc", () async { 607 | final data = await client.rpc("todos").select(); 608 | expect(data, [ 609 | {'id': 1, 'task': 'task 1', 'status': true}, 610 | {'id': 2, 'task': 'task 2', 'status': false} 611 | ]); 612 | }); 613 | 614 | test("rpc with custom headers", () async { 615 | apiKey = customApiKey; 616 | final data = await customHeadersClient.rpc("todos").select(); 617 | expect(data, [ 618 | {'id': 1, 'task': 'task 1', 'status': true}, 619 | {'id': 2, 'task': 'task 2', 'status': false} 620 | ]); 621 | }); 622 | }); 623 | 624 | group('realtime', () { 625 | /// Constructing Supabase query within a realtime callback caused exception 626 | /// https://github.com/supabase-community/supabase-flutter/issues/81 627 | test('Calling Postgrest within realtime callback', () async { 628 | client.channel('todos').on(RealtimeListenTypes.postgresChanges, 629 | ChannelFilter(event: '*', schema: 'public', table: 'todos'), (event, 630 | [_]) async { 631 | client.from('todos'); 632 | }).subscribe(); 633 | 634 | await Future.delayed(const Duration(milliseconds: 700)); 635 | 636 | await client.removeAllChannels(); 637 | }); 638 | }); 639 | }); 640 | 641 | group('realtime filter', () { 642 | test('can filter stream results with eq', () { 643 | handleRequests(mockServer, expectedFilter: 'status=eq.true'); 644 | final stream = 645 | client.from('todos').stream(primaryKey: ['id']).eq('status', true); 646 | expect( 647 | stream, 648 | emitsInOrder([ 649 | containsAllInOrder([ 650 | {'id': 1, 'task': 'task 1', 'status': true}, 651 | ]), 652 | containsAllInOrder([ 653 | {'id': 1, 'task': 'task 1', 'status': true}, 654 | {'id': 3, 'task': 'task 3', 'status': true}, 655 | ]), 656 | ]), 657 | ); 658 | }); 659 | 660 | test('can filter stream results with neq', () { 661 | handleRequests(mockServer, expectedFilter: 'id=neq.2'); 662 | final stream = 663 | client.from('todos').stream(primaryKey: ['id']).neq('id', 2); 664 | expect(stream, emits(isList)); 665 | }); 666 | 667 | test('can filter stream results with gt', () { 668 | handleRequests(mockServer, expectedFilter: 'id=gt.2'); 669 | final stream = 670 | client.from('todos').stream(primaryKey: ['id']).gt('id', 2); 671 | expect(stream, emits(isList)); 672 | }); 673 | 674 | test('can filter stream results with gte', () { 675 | handleRequests(mockServer, expectedFilter: 'id=gte.2'); 676 | final stream = 677 | client.from('todos').stream(primaryKey: ['id']).gte('id', 2); 678 | expect(stream, emits(isList)); 679 | }); 680 | 681 | test('can filter stream results with lt', () { 682 | handleRequests(mockServer, expectedFilter: 'id=lt.2'); 683 | final stream = 684 | client.from('todos').stream(primaryKey: ['id']).lt('id', 2); 685 | expect(stream, emits(isList)); 686 | }); 687 | 688 | test('can filter stream results with lte', () { 689 | handleRequests(mockServer, expectedFilter: 'id=lte.2'); 690 | final stream = 691 | client.from('todos').stream(primaryKey: ['id']).lte('id', 2); 692 | expect(stream, emits(isList)); 693 | }); 694 | }); 695 | } 696 | -------------------------------------------------------------------------------- /test/realtime_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:supabase/supabase.dart'; 5 | import 'package:test/test.dart'; 6 | import 'package:web_socket_channel/io.dart'; 7 | 8 | void main() { 9 | late HttpServer mockServer; 10 | late SupabaseClient client; 11 | late RealtimeChannel channel; 12 | late StreamSubscription subscription; 13 | 14 | group('Realtime subscriptions: ', () { 15 | setUp(() async { 16 | mockServer = await HttpServer.bind('localhost', 0); 17 | 18 | subscription = 19 | mockServer.transform(WebSocketTransformer()).listen((webSocket) { 20 | final channel = IOWebSocketChannel(webSocket); 21 | channel.stream.listen((request) { 22 | channel.sink.add(request); 23 | }); 24 | }); 25 | 26 | client = SupabaseClient( 27 | 'http://${mockServer.address.host}:${mockServer.port}', 28 | 'supabaseKey', 29 | ); 30 | 31 | channel = client.channel('realtime'); 32 | }); 33 | 34 | tearDown(() async { 35 | await client.dispose(); 36 | await client.removeAllChannels(); 37 | await subscription.cancel(); 38 | 39 | await Future.delayed(Duration(milliseconds: 100)); 40 | 41 | await mockServer.close(force: true); 42 | }); 43 | 44 | /// subscribe on existing subscription fail 45 | /// 46 | /// 1. create a subscription 47 | /// 2. subscribe on existing subscription 48 | /// 49 | /// expectation: 50 | /// - error 51 | test('subscribe on existing subscription fail', () { 52 | channel 53 | .on( 54 | RealtimeListenTypes.postgresChanges, 55 | ChannelFilter( 56 | event: 'INSERT', 57 | schema: 'public', 58 | table: 'countries', 59 | ), 60 | (payload, [ref]) {}) 61 | .subscribe( 62 | (event, [errorMsg]) {}, 63 | ); 64 | expect( 65 | () => channel.subscribe(), 66 | throwsA(const TypeMatcher()), 67 | ); 68 | }); 69 | 70 | /// two realtime channels 71 | /// 72 | /// 1. `realtime` channel 73 | /// 2. `anotherChannel` channel 74 | /// 75 | /// expectation: 76 | /// - 2 channels 77 | test('two realtime channels', () { 78 | client.channel('anotherChannel'); 79 | 80 | final channels = client.getChannels(); 81 | 82 | expect( 83 | channels.length, 84 | 2, 85 | ); 86 | }); 87 | 88 | /// remove realtime connection 89 | /// 90 | /// 1. create another Channel 91 | /// 2. remove `anotherChannel` 92 | 93 | /// expectation: 94 | /// - status is `ok` 95 | /// - only one channel 96 | test('remove realtime connection', () async { 97 | final anotherChannel = client.channel('anotherChannel'); 98 | 99 | channel.subscribe(); 100 | anotherChannel.subscribe(); 101 | 102 | expect( 103 | client.getChannels().length, 104 | 2, 105 | ); 106 | 107 | final status = await client.removeChannel(anotherChannel); 108 | 109 | expect(status, 'ok'); 110 | 111 | expect( 112 | client.getChannels().length, 113 | 1, 114 | ); 115 | }); 116 | 117 | /// remove multiple realtime connection 118 | /// 119 | /// 1. create another channel 120 | /// 2. remove both channels 121 | /// 122 | /// expectation: 123 | /// - status 1 without error 124 | /// - status 2 without error 125 | /// - no subscriptions 126 | test('remove multiple realtime connection', () async { 127 | final anotherChannel = client.channel('anotherChannel'); 128 | 129 | channel.subscribe(); 130 | anotherChannel.subscribe(); 131 | 132 | final status1 = await client.removeChannel(channel); 133 | final status2 = await client.removeChannel(anotherChannel); 134 | 135 | expect( 136 | status1, 137 | 'ok', 138 | ); 139 | expect( 140 | status2, 141 | 'ok', 142 | ); 143 | 144 | expect( 145 | client.getChannels().length, 146 | 0, 147 | ); 148 | }); 149 | 150 | /// remove all realtime connection 151 | /// 152 | /// 1. subscribe on table insert event 153 | /// 2. subscribe on table update event 154 | /// 3. remove subscriptions with removeAllSubscriptions() 155 | /// 156 | /// expectation: 157 | /// - result without error 158 | /// - result with 2 items 159 | /// - no subscriptions 160 | test('remove all realtime connection', () async { 161 | final anotherChannel = client.channel('anotherChannel'); 162 | 163 | channel.subscribe(); 164 | anotherChannel.subscribe(); 165 | 166 | final result1 = await client.removeAllChannels(); 167 | expect( 168 | result1, 169 | isNotEmpty, 170 | ); 171 | expect( 172 | result1.length, 173 | 2, 174 | ); 175 | 176 | expect( 177 | client.getChannels().length, 178 | isZero, 179 | ); 180 | }); 181 | }); 182 | } 183 | --------------------------------------------------------------------------------