From 18ae31ad61a052d8d47a9c525ffb23d58b73cb1a Mon Sep 17 00:00:00 2001 From: Vinzent Date: Mon, 20 Apr 2026 23:51:55 +0200 Subject: [PATCH 1/3] feat: optionally configure stream method to use a private channel close #1311 --- .../supabase/lib/src/supabase_query_builder.dart | 10 ++++++++-- .../lib/src/supabase_stream_builder.dart | 16 ++++++++++++++-- .../lib/src/supabase_stream_filter_builder.dart | 1 + 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/supabase/lib/src/supabase_query_builder.dart b/packages/supabase/lib/src/supabase_query_builder.dart index dd31713a3..1f4b8e4b7 100644 --- a/packages/supabase/lib/src/supabase_query_builder.dart +++ b/packages/supabase/lib/src/supabase_query_builder.dart @@ -25,10 +25,12 @@ class SupabaseQueryBuilder extends PostgrestQueryBuilder { /// Combines the current state of your table from PostgREST with changes from the realtime server to return real-time data from your table as a [Stream]. /// - /// Realtime is disabled by default for new tables. You can turn it on by [managing replication](https://supabase.com/docs/guides/realtime/extensions/postgres-changes#replication-setup). + /// Realtime is disabled by default for new tables. You can turn it on by [managing replication](https://supabase.com/docs/guides/realtime/subscribing-to-database-changes#enable-postgres-changes). /// /// Pass the list of primary key column names to [primaryKey], which will be used to update and delete the proper records internally as the stream receives real-time updates. /// + /// You may pass an optional [channelConfig] to configure the realtime channel to e.g., make it private. + /// /// It handles the lifecycle of the realtime connection and automatically refetches data from PostgREST when needed. /// /// Make sure to provide `onError` and `onDone` callbacks to [Stream.listen] to handle errors and completion of the stream. @@ -43,7 +45,10 @@ class SupabaseQueryBuilder extends PostgrestQueryBuilder { /// ```dart /// supabase.from('chats').stream(primaryKey: ['id']).eq('room_id','123').order('created_at').limit(20).listen(_onChatsReceived); /// ``` - SupabaseStreamFilterBuilder stream({required List primaryKey}) { + SupabaseStreamFilterBuilder stream({ + required List primaryKey, + RealtimeChannelConfig? channelConfig, + }) { assert(primaryKey.isNotEmpty, 'Please specify primary key column(s).'); return SupabaseStreamFilterBuilder( queryBuilder: this, @@ -52,6 +57,7 @@ class SupabaseQueryBuilder extends PostgrestQueryBuilder { schema: _schema, table: _table, primaryKey: primaryKey, + channelConfig: channelConfig, ); } } diff --git a/packages/supabase/lib/src/supabase_stream_builder.dart b/packages/supabase/lib/src/supabase_stream_builder.dart index b3a1c9f0f..32292a62d 100644 --- a/packages/supabase/lib/src/supabase_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_stream_builder.dart @@ -53,6 +53,16 @@ class SupabaseStreamBuilder extends Stream { final String _realtimeTopic; + /// Realtime channel config for the stream. + /// + /// Currently, only the [RealtimeChannelConfig.private] option affects the + /// `stream` method, since the `stream` method only handles postgres changes. + /// + /// Defaults to the constructor of [RealtimeChannelConfig] with its respective + /// default values, which means the channel will be a public channel by + /// default. + final RealtimeChannelConfig _channelConfig; + RealtimeChannel? _channel; final String _schema; @@ -89,12 +99,14 @@ class SupabaseStreamBuilder extends Stream { required String schema, required String table, required List primaryKey, + RealtimeChannelConfig? channelConfig, }) : _queryBuilder = queryBuilder, _realtimeTopic = realtimeTopic, _realtimeClient = realtimeClient, _schema = schema, _table = table, - _uniqueColumns = primaryKey; + _uniqueColumns = primaryKey, + _channelConfig = channelConfig ?? const RealtimeChannelConfig(); /// Orders the result with the specified [column]. /// @@ -167,7 +179,7 @@ class SupabaseStreamBuilder extends Stream { ); } - _channel = _realtimeClient.channel(_realtimeTopic); + _channel = _realtimeClient.channel(_realtimeTopic, _channelConfig); _channel! .onPostgresChanges( diff --git a/packages/supabase/lib/src/supabase_stream_filter_builder.dart b/packages/supabase/lib/src/supabase_stream_filter_builder.dart index 5cf09bc67..f8d2d90c3 100644 --- a/packages/supabase/lib/src/supabase_stream_filter_builder.dart +++ b/packages/supabase/lib/src/supabase_stream_filter_builder.dart @@ -8,6 +8,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { required super.schema, required super.table, required super.primaryKey, + super.channelConfig, }); /// Filters the results where [column] equals [value]. From 3ca81f411d015fd5ec3c6e874db085abfd2459a6 Mon Sep 17 00:00:00 2001 From: Vinzent Date: Tue, 21 Apr 2026 01:57:49 +0200 Subject: [PATCH 2/3] test: add missing mock tests --- packages/supabase/test/mock_test.dart | 30 ++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/supabase/test/mock_test.dart b/packages/supabase/test/mock_test.dart index dc5bf1a01..9950fbefe 100644 --- a/packages/supabase/test/mock_test.dart +++ b/packages/supabase/test/mock_test.dart @@ -23,6 +23,7 @@ void main() { Future handleRequests( HttpServer server, { String? expectedFilter, + bool? expectedPrivate, }) async { await for (final HttpRequest request in server) { final headers = request.headers; @@ -113,8 +114,9 @@ void main() { final requestJson = jsonDecode(request); final topic = requestJson['topic']; final ref = requestJson["ref"]; + final event = requestJson['event']; - if (requestJson["event"] == "phx_leave") { + if (event == 'phx_leave') { listeners.remove(topic); return; } @@ -126,10 +128,15 @@ void main() { final String? realtimeFilter = requestJson['payload']['config'] ['postgres_changes'] .first['filter']; + final bool isPrivate = + requestJson['payload']['config']['private'] as bool; if (expectedFilter != null) { expect(realtimeFilter, expectedFilter); } + if (expectedPrivate != null) { + expect(isPrivate, expectedPrivate); + } final replyString = jsonEncode({ 'event': 'phx_reply', @@ -682,6 +689,27 @@ void main() { }); }); + group('stream() channel config', () { + test('forwards channelConfig.private=true to realtime join payload', () { + handleRequests(mockServer, expectedPrivate: true); + + final stream = supabase.from('todos').stream( + primaryKey: ['id'], + channelConfig: const RealtimeChannelConfig(private: true), + ); + + expect(stream, emits(isList)); + }); + + test('uses default private=false when channelConfig is omitted', () { + handleRequests(mockServer, expectedPrivate: false); + + final stream = supabase.from('todos').stream(primaryKey: ['id']); + + expect(stream, emits(isList)); + }); + }); + group('Deprecated execute method', () { test('should work with deprecated execute method', () { handleRequests(mockServer); From 8e6ad9e4745845cae63bfcb260d333c85284db83 Mon Sep 17 00:00:00 2001 From: Vinzent Date: Tue, 21 Apr 2026 14:19:11 +0200 Subject: [PATCH 3/3] refactor: move to dedicated argument in stream method --- .../lib/src/supabase_query_builder.dart | 6 ++--- .../lib/src/supabase_stream_builder.dart | 23 +++++++++---------- .../src/supabase_stream_filter_builder.dart | 2 +- packages/supabase/test/mock_test.dart | 6 ++--- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/supabase/lib/src/supabase_query_builder.dart b/packages/supabase/lib/src/supabase_query_builder.dart index 1f4b8e4b7..a5624539d 100644 --- a/packages/supabase/lib/src/supabase_query_builder.dart +++ b/packages/supabase/lib/src/supabase_query_builder.dart @@ -29,7 +29,7 @@ class SupabaseQueryBuilder extends PostgrestQueryBuilder { /// /// Pass the list of primary key column names to [primaryKey], which will be used to update and delete the proper records internally as the stream receives real-time updates. /// - /// You may pass an optional [channelConfig] to configure the realtime channel to e.g., make it private. + /// The underlying [RealtimeChannel] is public by default. Set [private] to `true` to make it private, which requires additional RLS policies to be set up. See https://supabase.com/docs/guides/realtime/authorization for more details. /// /// It handles the lifecycle of the realtime connection and automatically refetches data from PostgREST when needed. /// @@ -47,7 +47,7 @@ class SupabaseQueryBuilder extends PostgrestQueryBuilder { /// ``` SupabaseStreamFilterBuilder stream({ required List primaryKey, - RealtimeChannelConfig? channelConfig, + bool private = false, }) { assert(primaryKey.isNotEmpty, 'Please specify primary key column(s).'); return SupabaseStreamFilterBuilder( @@ -57,7 +57,7 @@ class SupabaseQueryBuilder extends PostgrestQueryBuilder { schema: _schema, table: _table, primaryKey: primaryKey, - channelConfig: channelConfig, + private: private, ); } } diff --git a/packages/supabase/lib/src/supabase_stream_builder.dart b/packages/supabase/lib/src/supabase_stream_builder.dart index 32292a62d..8ef5380d5 100644 --- a/packages/supabase/lib/src/supabase_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_stream_builder.dart @@ -53,15 +53,9 @@ class SupabaseStreamBuilder extends Stream { final String _realtimeTopic; - /// Realtime channel config for the stream. - /// - /// Currently, only the [RealtimeChannelConfig.private] option affects the - /// `stream` method, since the `stream` method only handles postgres changes. - /// - /// Defaults to the constructor of [RealtimeChannelConfig] with its respective - /// default values, which means the channel will be a public channel by - /// default. - final RealtimeChannelConfig _channelConfig; + /// Whether the underlying [_channel] should be initialized as private + /// or not. Default is false, which means the channel is public. + final bool _private; RealtimeChannel? _channel; @@ -99,14 +93,14 @@ class SupabaseStreamBuilder extends Stream { required String schema, required String table, required List primaryKey, - RealtimeChannelConfig? channelConfig, + required bool private, }) : _queryBuilder = queryBuilder, _realtimeTopic = realtimeTopic, _realtimeClient = realtimeClient, _schema = schema, _table = table, _uniqueColumns = primaryKey, - _channelConfig = channelConfig ?? const RealtimeChannelConfig(); + _private = private; /// Orders the result with the specified [column]. /// @@ -179,7 +173,12 @@ class SupabaseStreamBuilder extends Stream { ); } - _channel = _realtimeClient.channel(_realtimeTopic, _channelConfig); + _channel = _realtimeClient.channel( + _realtimeTopic, + RealtimeChannelConfig( + private: _private, + ), + ); _channel! .onPostgresChanges( diff --git a/packages/supabase/lib/src/supabase_stream_filter_builder.dart b/packages/supabase/lib/src/supabase_stream_filter_builder.dart index f8d2d90c3..12316686f 100644 --- a/packages/supabase/lib/src/supabase_stream_filter_builder.dart +++ b/packages/supabase/lib/src/supabase_stream_filter_builder.dart @@ -8,7 +8,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { required super.schema, required super.table, required super.primaryKey, - super.channelConfig, + required super.private, }); /// Filters the results where [column] equals [value]. diff --git a/packages/supabase/test/mock_test.dart b/packages/supabase/test/mock_test.dart index 9950fbefe..a37952098 100644 --- a/packages/supabase/test/mock_test.dart +++ b/packages/supabase/test/mock_test.dart @@ -693,10 +693,8 @@ void main() { test('forwards channelConfig.private=true to realtime join payload', () { handleRequests(mockServer, expectedPrivate: true); - final stream = supabase.from('todos').stream( - primaryKey: ['id'], - channelConfig: const RealtimeChannelConfig(private: true), - ); + final stream = + supabase.from('todos').stream(primaryKey: ['id'], private: true); expect(stream, emits(isList)); });