Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,69 @@ unaffected. For `functions.invoke`, two things changed:
(3 to 5). This one is not a compile error, so replace any persisted `index` with `name`.

The enum also exposes `value`, the uppercase wire form, in place of `method.name.toUpperCase()`.

### Core types declare their subtyping intent with class modifiers

The core public types now carry Dart 3 class modifiers that state whether they are meant to be
extended, implemented, or neither. Nothing about their behaviour changed, but subtyping them in a
way the modifier disallows is now a compile error.

The clients and the request builders are `interface class`, so they can still be implemented (this
is what `mockito` and `mocktail` need) but no longer extended:

| Type | Modifier |
| --- | --- |
| `SupabaseClient` | `interface class` |
| `GoTrueClient` | `interface class` |
| `PostgrestClient` | `interface class` |
| `PostgrestBuilder` | `interface class` |
| `RealtimeClient` | `interface class` |
| `FunctionsClient` | `interface class` |
| `SupabaseStorageClient` | `interface class` |
| `StorageFileApi` | `interface class` |

If you were extending one of these to change its behaviour, wrap it instead:

```dart
// Before
class LoggingSupabaseClient extends SupabaseClient {
LoggingSupabaseClient(super.url, super.key);
}

// After
class LoggingSupabaseClient {
LoggingSupabaseClient(this._inner);

final SupabaseClient _inner;

SupabaseQueryBuilder from(String table) {
print('from($table)');
return _inner.from(table);
}
Comment on lines +350 to +367

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- MIGRATION.md excerpt ---'
sed -n '320,390p' MIGRATION.md
printf '%s\n' '--- SupabaseClient declarations/usages ---'
rg -n --glob '*.dart' 'class SupabaseClient|abstract class SupabaseClient|interface class SupabaseClient|final class SupabaseClient|SupabaseClient\(' .
printf '%s\n' '--- wrapper example references ---'
rg -n 'LoggingSupabaseClient|wrap it instead|SupabaseClient' MIGRATION.md

Repository: supabase/supabase-flutter

Length of output: 5784


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- SupabaseClient public API ---'
sed -n '45,190p' packages/supabase/lib/src/supabase_client.dart
printf '%s\n' '--- Static contract check for the migration example ---'
python3 - <<'PY'
from pathlib import Path
import re

doc = Path("MIGRATION.md").read_text()
match = re.search(
    r"class LoggingSupabaseClient \{\n(?P<body>.*?)\n\}",
    doc,
    re.S,
)
assert match, "LoggingSupabaseClient wrapper not found"
body = match.group("body")
methods = re.findall(r"^\s*(?:[A-Za-z_<>,?]+\s+)+([A-Za-z_]\w*)\s*\(", body, re.M)
fields = re.findall(r"^\s*final\s+[^;]+;", body, re.M)
print("wrapper_has_implements:", bool(re.search(r"\bimplements\s+SupabaseClient\b", match.group(0))))
print("wrapper_methods:", methods)
print("wrapper_fields:", fields)
PY

Repository: supabase/supabase-flutter

Length of output: 5094


Clarify the wrapper’s type contract.

LoggingSupabaseClient does not implement SupabaseClient and forwards only from, so it cannot replace a SupabaseClient where that type is required. State that this is a partial composition example, or delegate the complete public API.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MIGRATION.md` around lines 350 - 367, Clarify the LoggingSupabaseClient
example as partial composition that cannot substitute for SupabaseClient, since
it only forwards from. State that callers requiring SupabaseClient must use a
complete delegating wrapper or retain the original client type.

}
```

Mocks are unaffected, because `implements` is still allowed:

```dart
// Still compiles
class MockSupabaseClient extends Mock implements SupabaseClient {}
```

The two storage abstractions you are meant to plug your own implementation into are
`abstract interface class`, so they must be implemented rather than extended. Both declare only
abstract members, so this is a one-word change at the use site:

```dart
// Before
class MyLocalStorage extends LocalStorage { /* ... */ }
class MyPkceStorage extends GotrueAsyncStorage { /* ... */ }

// After
class MyLocalStorage implements LocalStorage { /* ... */ }
class MyPkceStorage implements GotrueAsyncStorage { /* ... */ }
```

Finally, `AuthState` is a `final class`. It is a plain value carrying an `AuthChangeEvent` and a
`Session`, and `AuthChangeEvent` is already an enum, so switching over auth state changes is
unchanged.
2 changes: 1 addition & 1 deletion packages/functions_client/lib/src/functions_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import 'package:logging/logging.dart';
import 'package:supabase_common/supabase_common.dart';
import 'package:yet_another_json_isolate/yet_another_json_isolate.dart';

class FunctionsClient {
interface class FunctionsClient {
final String _url;
final Map<String, String> _headers;
final http.Client? _httpClient;
Expand Down
2 changes: 1 addition & 1 deletion packages/gotrue/lib/src/fetch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import 'package:meta/meta.dart';
import 'package:supabase_common/supabase_common.dart';

@internal
class GotrueFetch {
final class GotrueFetch {
final Client? httpClient;

const GotrueFetch([this.httpClient]);
Expand Down
2 changes: 1 addition & 1 deletion packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class _SessionState {
///
/// Set [flowType] to [AuthFlowType.implicit] to perform old implicit auth flow.
/// {@endtemplate}
class GoTrueClient {
interface class GoTrueClient {
/// Namespace for the GoTrue API methods. These can be used for example to get
/// a user from a JWT in a server environment or reset a user's password.
late final GoTrueAdminApi admin;
Expand Down
2 changes: 1 addition & 1 deletion packages/gotrue/lib/src/types/auth_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'package:gotrue/src/constants.dart';
import 'package:gotrue/src/types/session.dart';
import 'package:gotrue/src/types/sign_out_reason.dart';

class AuthState {
final class AuthState {
final AuthChangeEvent event;
final Session? session;

Expand Down
2 changes: 1 addition & 1 deletion packages/gotrue/lib/src/types/gotrue_async_storage.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Interface to provide async storage to store pkce tokens.
abstract class GotrueAsyncStorage {
abstract interface class GotrueAsyncStorage {
const GotrueAsyncStorage();

/// Retrieves an item asynchronously from the storage with the key.
Expand Down
2 changes: 1 addition & 1 deletion packages/gotrue/test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const sessionDataUserId = '4d2583da-8de4-49d3-9cd1-37a9a74f55bd';
return (accessToken: accessToken, sessionString: sessionString);
}

class TestAsyncStorage extends GotrueAsyncStorage {
class TestAsyncStorage implements GotrueAsyncStorage {
final Map<String, String> _map = {};
@override
Future<String?> getItem({required String key}) async {
Expand Down
2 changes: 1 addition & 1 deletion packages/postgrest/lib/src/postgrest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'package:yet_another_json_isolate/yet_another_json_isolate.dart';

/// A PostgREST api client written in Dartlang. The goal of this library is to
/// make an "ORM-like" restful interface.
class PostgrestClient {
interface class PostgrestClient {
/// HTTP status codes that trigger an automatic retry by default.
static const Set<int> defaultRetryableStatusCodes = {503, 520};

Expand Down
2 changes: 1 addition & 1 deletion packages/postgrest/lib/src/postgrest_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ String? _emptyPreferAsNull(String? prefer) =>
/// When using [_converter], [R] is the input and [S] is the output
/// Otherwise [S] and [R] are the same
@immutable
class PostgrestBuilder<T, S, R> implements Future<T> {
interface class PostgrestBuilder<T, S, R> implements Future<T> {
final _RequestConfig _config;
final PostgrestConverter<S, R>? _converter;
final _log = Logger('supabase.postgrest');
Expand Down
2 changes: 1 addition & 1 deletion packages/realtime_client/lib/src/realtime_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ enum RealtimeHeartbeatStatus {
/// **Platform notes:**
/// - Works on all Dart platforms (Flutter mobile/desktop, web, server).
/// - On web, the underlying [WebSocketChannel] uses the browser WebSocket API.
class RealtimeClient {
interface class RealtimeClient {
String? accessToken;
List<RealtimeChannel> channels = [];
final String endPoint;
Expand Down
55 changes: 26 additions & 29 deletions packages/realtime_client/test/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:mocktail/mocktail.dart';
import 'package:realtime_client/realtime_client.dart';
import 'package:realtime_client/src/constants.dart';
import 'package:realtime_client/src/push.dart';
import 'package:realtime_client/src/types.dart';
import 'package:test/test.dart';

const _expiredToken = 'expired-token';

void main() {
late RealtimeClient socket;
late RealtimeChannel channel;
Expand Down Expand Up @@ -132,15 +135,13 @@ void main() {
// https://github.com/supabase/supabase-flutter/issues/1363.
test("swallows FormatException with 'InvalidJWTToken' from setAuth and "
"still emits 'subscribed' status", () async {
final throwingSocket = _SetAuthThrowingSocket(
'/socket',
thrown: const FormatException(
final throwingSocket = _setAuthThrowingSocket(
const FormatException(
'InvalidJWTToken: Invalid value for JWT claim "exp" with value 0',
),
);
throwingSocket.accessToken = 'expired-token';

final localChannel = throwingSocket.channel('topic');
final localChannel = RealtimeChannel('topic', throwingSocket);

RealtimeSubscribeStatus? status;
localChannel.subscribe((s, _) => status = s);
Expand All @@ -150,7 +151,7 @@ void main() {
await Future<void>.value();
await Future<void>.value();

expect(throwingSocket.setAuthCalls, 1);
verify(() => throwingSocket.setAuth(_expiredToken)).called(1);
expect(
status,
RealtimeSubscribeStatus.subscribed,
Expand All @@ -162,13 +163,11 @@ void main() {

test("non-InvalidJWTToken FormatExceptions from setAuth still abort the "
"rejoin handler", () async {
final throwingSocket = _SetAuthThrowingSocket(
'/socket',
thrown: const FormatException('some other parsing failure'),
final throwingSocket = _setAuthThrowingSocket(
const FormatException('some other parsing failure'),
);
throwingSocket.accessToken = 'some-token';

final localChannel = throwingSocket.channel('topic');
final localChannel = RealtimeChannel('topic', throwingSocket);

RealtimeSubscribeStatus? status;
// Use runZonedGuarded so the rethrown async error does not pollute
Expand All @@ -185,7 +184,7 @@ void main() {
},
);

expect(throwingSocket.setAuthCalls, 1);
verify(() => throwingSocket.setAuth(_expiredToken)).called(1);
expect(
status,
isNull,
Expand Down Expand Up @@ -1365,23 +1364,21 @@ void main() {
});
}

class _SetAuthThrowingSocket extends RealtimeClient {
_SetAuthThrowingSocket(super.endPoint, {required this.thrown});

final FormatException thrown;
int setAuthCalls = 0;

@override
Future<void> connect() async {
// No-op: avoid opening a real WebSocket so async transport failures
// don't leak into the test runner zone.
}

@override
Future<void> setAuth(String? token) async {
setAuthCalls++;
throw thrown;
}
class _MockSocket extends Mock implements RealtimeClient {}

/// A socket whose [RealtimeClient.setAuth] throws [thrown]. It reports itself
/// as already connected so no real WebSocket is opened and async transport
/// failures don't leak into the test runner zone.
_MockSocket _setAuthThrowingSocket(FormatException thrown) {
final socket = _MockSocket();
when(() => socket.endPoint).thenReturn('/socket');
when(() => socket.timeout).thenReturn(Constants.defaultTimeout);
when(() => socket.reconnectAfterMs).thenReturn((_) => 0);
when(() => socket.isConnected).thenReturn(true);
when(() => socket.accessToken).thenReturn(_expiredToken);
when(() => socket.makeRef()).thenReturn('1');
when(() => socket.setAuth(any())).thenThrow(thrown);
return socket;
}

class _OptsCapturingChannel extends RealtimeChannel {
Expand Down
83 changes: 35 additions & 48 deletions packages/realtime_client/test/socket_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -595,23 +595,14 @@ void main() {
final mockedChannel2 = MockChannel();
when(() => mockedChannel2.joinRef).thenReturn('2');

const tTopic1 = 'topic-1';
const tTopic2 = 'topic-2';
final mockedSocket = RealtimeClient(socketEndpoint);
mockedSocket.channels.addAll([mockedChannel1, mockedChannel2]);

final mockedSocket = SocketWithMockedChannel(socketEndpoint);
mockedSocket.mockedChannelLooker.addAll({
tTopic1: mockedChannel1,
tTopic2: mockedChannel2,
});

final channel1 = mockedSocket.channel(tTopic1);
final channel2 = mockedSocket.channel(tTopic2);

mockedSocket.remove(channel1);
mockedSocket.remove(mockedChannel1);
expect(mockedSocket.channels, hasLength(1));

final foundChannel = mockedSocket.channels[0];
expect(foundChannel, channel2);
expect(foundChannel, mockedChannel2);
});

test('keeps the other channels when none of them have joined', () {
Expand Down Expand Up @@ -1090,29 +1081,24 @@ void main() {
() => mockedChannel2.push(ChannelEvent.accessToken, pushPayload),
).thenReturn(MockPush());

const tTopic1 = 'topic-1';
const tTopic2 = 'topic-2';

final mockedSocket = SocketWithMockedChannel(socketEndpoint);
mockedSocket.mockedChannelLooker.addAll({
tTopic1: mockedChannel1,
tTopic2: mockedChannel2,
});

final channel1 = mockedSocket.channel(tTopic1);
final channel2 = mockedSocket.channel(tTopic2);
final mockedSocket = RealtimeClient(socketEndpoint);
mockedSocket.channels.addAll([mockedChannel1, mockedChannel2]);

await mockedSocket.setAuth(token);

expect(mockedSocket.accessToken, token);

verify(() => channel1.updateJoinPayload(updateJoinPayload)).called(1);
verify(() => channel2.updateJoinPayload(updateJoinPayload)).called(1);
verify(
() => channel1.push(ChannelEvent.accessToken, pushPayload),
() => mockedChannel1.updateJoinPayload(updateJoinPayload),
).called(1);
verify(
() => channel2.push(ChannelEvent.accessToken, pushPayload),
() => mockedChannel2.updateJoinPayload(updateJoinPayload),
).called(1);
verify(
() => mockedChannel1.push(ChannelEvent.accessToken, pushPayload),
).called(1);
verify(
() => mockedChannel2.push(ChannelEvent.accessToken, pushPayload),
).called(1);
},
);
Expand Down Expand Up @@ -1143,20 +1129,12 @@ void main() {
() => mockedChannel3.push(ChannelEvent.accessToken, any()),
).thenReturn(MockPush());

const tTopic1 = 'test-topic1';
const tTopic2 = 'test-topic2';
const tTopic3 = 'test-topic3';

final mockedSocket = SocketWithMockedChannel(socketEndpoint);
mockedSocket.mockedChannelLooker.addAll({
tTopic1: mockedChannel1,
tTopic2: mockedChannel2,
tTopic3: mockedChannel3,
});

final channel1 = mockedSocket.channel(tTopic1);
final channel2 = mockedSocket.channel(tTopic2);
final channel3 = mockedSocket.channel(tTopic3);
final mockedSocket = RealtimeClient(socketEndpoint);
mockedSocket.channels.addAll([
mockedChannel1,
mockedChannel2,
mockedChannel3,
]);

const authToken = 'sb-key';
final expectedPushPayload = {'access_token': authToken};
Expand All @@ -1170,23 +1148,32 @@ void main() {
expect(mockedSocket.accessToken, authToken);

verify(
() => channel1.updateJoinPayload(expectedUpdateJoinPayload),
() => mockedChannel1.updateJoinPayload(expectedUpdateJoinPayload),
).called(1);
verify(
() => channel2.updateJoinPayload(expectedUpdateJoinPayload),
() => mockedChannel2.updateJoinPayload(expectedUpdateJoinPayload),
).called(1);
verify(
() => channel3.updateJoinPayload(expectedUpdateJoinPayload),
() => mockedChannel3.updateJoinPayload(expectedUpdateJoinPayload),
).called(1);

verify(
() => channel1.push(ChannelEvent.accessToken, expectedPushPayload),
() => mockedChannel1.push(
ChannelEvent.accessToken,
expectedPushPayload,
),
).called(1);
verifyNever(
() => channel2.push(ChannelEvent.accessToken, expectedPushPayload),
() => mockedChannel2.push(
ChannelEvent.accessToken,
expectedPushPayload,
),
);
verify(
() => channel3.push(ChannelEvent.accessToken, expectedPushPayload),
() => mockedChannel3.push(
ChannelEvent.accessToken,
expectedPushPayload,
),
).called(1);
},
);
Expand Down
Loading
Loading