From a21283e3ee4a5ee1758e29cb343d6f2b03dd4d5e Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Jan 2025 11:10:56 -0300 Subject: [PATCH 1/8] Mark some API as Obsolete, since it diverges from JS reference implementation --- Realtime/Client.cs | 4 +- Realtime/Interfaces/IRealtimeClient.cs | 3 +- .../PostgresChanges/PostgresChangesOptions.cs | 1 + Realtime/RealtimeChannel.cs | 25 ++++++++-- RealtimeTests/ChannelPostgresChangesTests.cs | 50 ++++++++----------- RealtimeTests/ChannelTests.cs | 24 +++------ RealtimeTests/ClientTests.cs | 6 +-- 7 files changed, 56 insertions(+), 57 deletions(-) diff --git a/Realtime/Client.cs b/Realtime/Client.cs index 1e54549..b0c91b0 100644 --- a/Realtime/Client.cs +++ b/Realtime/Client.cs @@ -47,7 +47,7 @@ public class Client : IRealtimeClient public ClientOptions Options { get; } private Func>? _getHeaders { get; set; } - + /// public Func>? GetHeaders { @@ -55,7 +55,7 @@ public Func>? GetHeaders set { _getHeaders = value; - + if (Socket != null) Socket.GetHeaders = value; } diff --git a/Realtime/Interfaces/IRealtimeClient.cs b/Realtime/Interfaces/IRealtimeClient.cs index a8f9cf7..a7f1c94 100644 --- a/Realtime/Interfaces/IRealtimeClient.cs +++ b/Realtime/Interfaces/IRealtimeClient.cs @@ -15,7 +15,7 @@ namespace Supabase.Realtime.Interfaces; /// /// /// -public interface IRealtimeClient: IGettableHeaders +public interface IRealtimeClient : IGettableHeaders where TSocket : IRealtimeSocket where TChannel : IRealtimeChannel { @@ -95,6 +95,7 @@ public interface IRealtimeClient: IGettableHeaders /// /// /// + [Obsolete("Please use Channel(string channelName) instead.")] TChannel Channel(string database = "realtime", string schema = "public", string table = "*", string? column = null, string? value = null, Dictionary? parameters = null); diff --git a/Realtime/PostgresChanges/PostgresChangesOptions.cs b/Realtime/PostgresChanges/PostgresChangesOptions.cs index 973fa33..1302e81 100644 --- a/Realtime/PostgresChanges/PostgresChangesOptions.cs +++ b/Realtime/PostgresChanges/PostgresChangesOptions.cs @@ -71,6 +71,7 @@ public enum ListenType /// The parameters passed to the server /// [JsonProperty("parameters", NullValueHandling = NullValueHandling.Ignore)] + [System.Obsolete("The Parameters property is deprecated and will be removed in a future version.")] public Dictionary? Parameters { get; set; } /// diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index ed429a3..6526487 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -324,10 +324,28 @@ private void NotifyMessageReceived(SocketResponse message) } /// - /// Add a postgres changes listener. Should be paired with . + /// Registers and adds a postgres change handler. + /// + /// The handler to process the event. + /// The type of event this callback should process. + /// The schema to listen to. + /// The table to listen to. + /// The filter to apply. + /// + public RealtimeChannel OnPostgresChange(PostgresChangesHandler postgresChangeHandler, ListenType listenType = ListenType.All, string schema = "public", string? table = null, string? filter = null) + { + var postgresChangesOptions = new PostgresChangesOptions(schema, table, listenType, filter); + Register(postgresChangesOptions); + AddPostgresChangeHandler(listenType, postgresChangeHandler); + return this; + } + + /// + /// Adds a postgres changes listener. Should be paired with . /// /// The type of event this callback should process. /// + [Obsolete("Use OnPostgresChange instead.")] public void AddPostgresChangeHandler(ListenType listenType, PostgresChangesHandler postgresChangeHandler) { if (!_postgresChangesHandlers.ContainsKey(listenType)) @@ -425,6 +443,7 @@ private void NotifyPostgresChanges(EventType eventType, PostgresChangesResponse /// /// /// + [Obsolete("Use OnPostgresChange instead.")] public IRealtimeChannel Register(PostgresChangesOptions postgresChangesOptions) { PostgresChangesOptions.Add(postgresChangesOptions); @@ -694,7 +713,7 @@ private void HandleJoinResponse(IRealtimePush s _isRejoining = false; NotifyErrorOccurred(new RealtimeException(message.Json) - { Reason = FailureHint.Reason.ChannelJoinFailure }); + { Reason = FailureHint.Reason.ChannelJoinFailure }); break; } } @@ -733,7 +752,7 @@ internal void HandleSocketMessage(SocketResponse message) break; case PhoenixStatusError: NotifyErrorOccurred(new RealtimeException(message.Json) - { Reason = FailureHint.Reason.ChannelJoinFailure }); + { Reason = FailureHint.Reason.ChannelJoinFailure }); break; } diff --git a/RealtimeTests/ChannelPostgresChangesTests.cs b/RealtimeTests/ChannelPostgresChangesTests.cs index 6ea3a77..f611f4b 100644 --- a/RealtimeTests/ChannelPostgresChangesTests.cs +++ b/RealtimeTests/ChannelPostgresChangesTests.cs @@ -38,15 +38,13 @@ public async Task ChannelPayloadReturnsModel() { var tsc = new TaskCompletionSource(); - var channel = _socketClient!.Channel("example"); - channel.Register(new PostgresChangesOptions("public", "*")); - channel.AddPostgresChangeHandler(ListenType.Inserts, (_, changes) => - { - var model = changes.Model(); - tsc.SetResult(model != null); - }); - - await channel.Subscribe(); + await _socketClient!.Channel("example") + .OnPostgresChange((_, changes) => + { + var model = changes.Model(); + tsc.SetResult(model != null); + }, ListenType.Inserts) + .Subscribe(); await _restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client Models a response? ✅" }); @@ -59,11 +57,10 @@ public async Task ChannelReceivesInsertCallback() { var tsc = new TaskCompletionSource(); - var channel = _socketClient!.Channel("realtime", "public", "todos"); + await _socketClient!.Channel("realtime:public:todos") + .OnPostgresChange((_, _) => tsc.SetResult(true), ListenType.Inserts, table: "todos") + .Subscribe(); - channel.AddPostgresChangeHandler(ListenType.Inserts, (_, _) => tsc.SetResult(true)); - - await channel.Subscribe(); await _restClient!.Table() .Insert(new Todo { UserId = 1, Details = "Client receives insert callback? ✅" }); @@ -83,9 +80,8 @@ public async Task ChannelReceivesUpdateCallback() var oldDetails = model.Details; var newDetails = $"I'm an updated item ✏️ - {DateTime.Now}"; - var channel = _socketClient!.Channel("realtime", "public", "todos"); - - channel.AddPostgresChangeHandler(ListenType.Updates, (_, changes) => + await _socketClient!.Channel("realtime:public:todos") + .OnPostgresChange((_, changes) => { var oldModel = changes.OldModel(); @@ -101,9 +97,8 @@ public async Task ChannelReceivesUpdateCallback() } tsc.SetResult(true); - }); - - await channel.Subscribe(); + }, ListenType.Updates, table: "todos") + .Subscribe(); await _restClient.Table() .Set(x => x.Details!, newDetails) @@ -119,11 +114,9 @@ public async Task ChannelReceivesDeleteCallback() { var tsc = new TaskCompletionSource(); - var channel = _socketClient!.Channel("realtime", "public", "todos"); - - channel.AddPostgresChangeHandler(ListenType.Deletes, (_, _) => tsc.SetResult(true)); - - await channel.Subscribe(); + await _socketClient!.Channel("realtime:public:todos") + .OnPostgresChange((_, _) => tsc.SetResult(true), ListenType.Deletes, table: "todos") + .Subscribe(); var result = await _restClient!.Table().Get(); var model = result.Models.Last(); @@ -143,9 +136,7 @@ public async Task ChannelReceivesWildcardCallback() List tasks = new List { insertTsc.Task, updateTsc.Task, deleteTsc.Task }; - var channel = _socketClient!.Channel("realtime", "public", "todos"); - - channel.AddPostgresChangeHandler(ListenType.All, (_, changes) => + await _socketClient!.Channel("realtime:public:todos").OnPostgresChange((_, changes) => { switch (changes.Payload?.Data?.Type) { @@ -159,12 +150,11 @@ public async Task ChannelReceivesWildcardCallback() deleteTsc.SetResult(true); break; } - }); - await channel.Subscribe(); + }, ListenType.All, table: "todos").Subscribe(); var modeledResponse = await _restClient!.Table().Insert(new Todo - { UserId = 1, Details = "Client receives wildcard callbacks? ✅" }); + { UserId = 1, Details = "Client receives wildcard callbacks? ✅" }); var newModel = modeledResponse.Models.First(); await _restClient.Table().Set(x => x.Details!, "And edits.").Match(newModel).Update(); diff --git a/RealtimeTests/ChannelTests.cs b/RealtimeTests/ChannelTests.cs index ec4a44d..f78b34b 100644 --- a/RealtimeTests/ChannelTests.cs +++ b/RealtimeTests/ChannelTests.cs @@ -37,7 +37,7 @@ public async Task ChannelCloseEventHandler() { var tsc = new TaskCompletionSource(); - var channel = _socketClient!.Channel("realtime", "public", "todos"); + var channel = _socketClient!.Channel("realtime:public:todos"); channel.AddStateChangedHandler((_, state) => { if (state == ChannelState.Closed) @@ -57,7 +57,7 @@ public async Task ChannelSupportsWalrusArray() Todo? result = null; var tsc = new TaskCompletionSource(); - var channel = _socketClient!.Channel("realtime", "public", "todos"); + var channel = _socketClient!.Channel("realtime:public:todos"); var numbers = new List { 4, 5, 6 }; await channel.Subscribe(); @@ -74,24 +74,12 @@ public async Task ChannelSupportsWalrusArray() CollectionAssert.AreEqual(numbers, result?.Numbers); } - [TestMethod("Channel: Sends Join parameters")] - public async Task ChannelSendsJoinParameters() - { - var parameters = new Dictionary { { "key", "value" } }; - var channel = _socketClient!.Channel("realtime", "public", "todos", parameters: parameters); - - await channel.Subscribe(); - - var serialized = JsonConvert.SerializeObject(channel.JoinPush?.Payload); - Assert.IsTrue(serialized.Contains("\"key\":\"value\"")); - } - [TestMethod("Channel: Returns single subscription per unique topic.")] public async Task ChannelJoinsDuplicateSubscription() { - var subscription1 = _socketClient!.Channel("realtime", "public", "todos"); - var subscription2 = _socketClient!.Channel("realtime", "public", "todos"); - var subscription3 = _socketClient!.Channel("realtime", "public", "todos", "user_id", "1"); + var subscription1 = _socketClient!.Channel("realtime:public:todos"); + var subscription2 = _socketClient!.Channel("realtime:public:todos"); + var subscription3 = _socketClient!.Channel("realtime:public:todos:user_id:1"); Assert.AreEqual(subscription1.Topic, subscription2.Topic); @@ -100,7 +88,7 @@ public async Task ChannelJoinsDuplicateSubscription() Assert.AreEqual(subscription1.HasJoinedOnce, subscription2.HasJoinedOnce); Assert.AreNotEqual(subscription1.HasJoinedOnce, subscription3.HasJoinedOnce); - var subscription4 = _socketClient!.Channel("realtime", "public", "todos"); + var subscription4 = _socketClient!.Channel("realtime:public:todos"); Assert.AreEqual(subscription1.HasJoinedOnce, subscription4.HasJoinedOnce); } diff --git a/RealtimeTests/ClientTests.cs b/RealtimeTests/ClientTests.cs index 390e173..83bb30c 100644 --- a/RealtimeTests/ClientTests.cs +++ b/RealtimeTests/ClientTests.cs @@ -137,13 +137,13 @@ public async Task ClientCanReconnectAfterProgrammaticDisconnect() public async Task ClientCanSetHeaders() { client!.Disconnect(); - + client!.GetHeaders = () => new Dictionary() { { "testing", "123" } }; await client.ConnectAsync(); - + Assert.IsNotNull(client!); Assert.IsNotNull(client!.Socket); Assert.IsNotNull(client!.Socket.GetHeaders); - Assert.AreEqual("123",client.Socket.GetHeaders()["testing"]); + Assert.AreEqual("123", client.Socket.GetHeaders()["testing"]); } } \ No newline at end of file From b1849790b21e6c00d8618d7bdfdb903ab12b9ab3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Jan 2025 11:50:36 -0300 Subject: [PATCH 2/8] guard against duplicating realtime prefix for channel name --- Realtime/Client.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Realtime/Client.cs b/Realtime/Client.cs index b0c91b0..d5ab64e 100644 --- a/Realtime/Client.cs +++ b/Realtime/Client.cs @@ -345,7 +345,7 @@ public void SetAuth(string jwt) /// public RealtimeChannel Channel(string channelName) { - var topic = $"realtime:{channelName}"; + var topic = channelName.StartsWith("realtime:") ? channelName : $"realtime:{channelName}"; if (_subscriptions.TryGetValue(topic, out var channel)) return channel; From 6886198dadd4183dce3a9aa13d6081e5aaf77f23 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Jan 2025 11:50:55 -0300 Subject: [PATCH 3/8] fix ignore null table filter to be sent --- Realtime/PostgresChanges/PostgresChangesOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Realtime/PostgresChanges/PostgresChangesOptions.cs b/Realtime/PostgresChanges/PostgresChangesOptions.cs index 1302e81..5afe418 100644 --- a/Realtime/PostgresChanges/PostgresChangesOptions.cs +++ b/Realtime/PostgresChanges/PostgresChangesOptions.cs @@ -58,7 +58,7 @@ public enum ListenType /// /// The table for this listener, can be: `*` matching all tables in schema. /// - [JsonProperty("table")] + [JsonProperty("table", NullValueHandling = NullValueHandling.Ignore)] public string? Table { get; set; } /// From 2c1e2abbdeefcf7eb744f46951f5c6e8bb86d204 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Jan 2025 11:51:09 -0300 Subject: [PATCH 4/8] fix tests by using supabase cli --- RealtimeTests/Helpers.cs | 7 +- RealtimeTests/RealtimeTests.csproj | 2 +- supabase/.gitignore | 4 + supabase/config.toml | 278 ++++++++++++++++++++ supabase/migrations/20250124142807_init.sql | 185 +++++++++++++ 5 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20250124142807_init.sql diff --git a/RealtimeTests/Helpers.cs b/RealtimeTests/Helpers.cs index 276c49e..ffd05f8 100644 --- a/RealtimeTests/Helpers.cs +++ b/RealtimeTests/Helpers.cs @@ -7,11 +7,10 @@ namespace RealtimeTests; internal static class Helpers { - private const string ApiKey = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIiLCJpYXQiOjE2NzEyMzc4NzMsImV4cCI6MjAwMjc3Mzk5MywiYXVkIjoiIiwic3ViIjoiIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifQ.qoYdljDZ9rjfs1DKj5_OqMweNtj7yk20LZKlGNLpUO8"; + private const string ApiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"; - private const string SocketEndpoint = "ws://realtime-dev.localhost:4000/socket"; - private const string RestEndpoint = "http://localhost:3000"; + private const string SocketEndpoint = "ws://127.0.0.1:54321/realtime/v1"; + private const string RestEndpoint = "http://localhost:54321/rest/v1"; public static Supabase.Postgrest.Client RestClient() => new(RestEndpoint, new Supabase.Postgrest.ClientOptions()); diff --git a/RealtimeTests/RealtimeTests.csproj b/RealtimeTests/RealtimeTests.csproj index 9eead2c..ffa86bd 100644 --- a/RealtimeTests/RealtimeTests.csproj +++ b/RealtimeTests/RealtimeTests.csproj @@ -1,7 +1,7 @@  false - net8.0 + net9.0 enable latest CS8600;CS8602;CS8603 diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..a3ad880 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,4 @@ +# Supabase +.branches +.temp +.env diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..459f7cc --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,278 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "realtime-csharp" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 + +# Use these configurations to customize your Edge Function. +# [functions.MY_FUNCTION_NAME] +# enabled = true +# verify_jwt = true +# import_map = "./functions/MY_FUNCTION_NAME/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +# entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20250124142807_init.sql b/supabase/migrations/20250124142807_init.sql new file mode 100644 index 0000000..718e09b --- /dev/null +++ b/supabase/migrations/20250124142807_init.sql @@ -0,0 +1,185 @@ +-- Create a second schema +CREATE SCHEMA personal; + +-- USERS +CREATE TYPE public.user_status AS ENUM ('ONLINE', 'OFFLINE'); +CREATE TABLE public.users +( + username text primary key, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + favorite_numbers int[] DEFAULT null, + data jsonb DEFAULT null, + age_range int4range DEFAULT null, + status user_status DEFAULT 'ONLINE':: public.user_status, + catchphrase tsvector DEFAULT null +); +ALTER TABLE public.users + REPLICA IDENTITY FULL; -- Send "previous data" to supabase +COMMENT + ON COLUMN public.users.data IS 'For unstructured data and prototyping.'; + +CREATE TYPE public.todo_status AS ENUM ('NOT STARTED', 'STARTED', 'COMPLETED'); +create table public.todos +( + id bigint generated by default as identity not null, + name text null, + notes text null, + done boolean null default false, + details text null, + inserted_at timestamp without time zone null default now(), + numbers int[] null, + user_id text null, + status public.todo_status not null default 'NOT STARTED'::todo_status, + constraint todos_pkey primary key (id) +) tablespace pg_default; + +ALTER publication supabase_realtime add table public.todos; +alter table public.todos replica identity full; + +-- CHANNELS +CREATE TABLE public.channels +( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + data jsonb DEFAULT null, + slug text +); +ALTER TABLE public.users + REPLICA IDENTITY FULL; -- Send "previous data" to supabase +COMMENT + ON COLUMN public.channels.data IS 'For unstructured data and prototyping.'; + +-- MESSAGES +CREATE TABLE public.messages +( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + data jsonb DEFAULT null, + message text, + username text REFERENCES users NOT NULL, + channel_id bigint REFERENCES channels NOT NULL +); +ALTER TABLE public.messages + REPLICA IDENTITY FULL; -- Send "previous data" to supabase +COMMENT + ON COLUMN public.messages.data IS 'For unstructured data and prototyping.'; + +create table "public"."kitchen_sink" +( + "id" serial primary key, + "string_value" varchar(255) null, + "bool_value" BOOL DEFAULT false, + "unique_value" varchar(255) UNIQUE, + "int_value" INT null, + "float_value" FLOAT null, + "double_value" DOUBLE PRECISION null, + "datetime_value" timestamp null, + "datetime_value_1" timestamp null, + "datetime_pos_infinite_value" timestamp null, + "datetime_neg_infinite_value" timestamp null, + "list_of_strings" TEXT[] null, + "list_of_datetimes" DATE[] null, + "list_of_ints" INT[] null, + "list_of_floats" FLOAT[] null, + "int_range" INT4RANGE null +); + +CREATE TABLE public.movie +( + id serial primary key, + created_at timestamp without time zone NOT NULL DEFAULT now(), + name character varying(255) NULL +); + +CREATE TABLE public.person +( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp without time zone NOT NULL DEFAULT now(), + first_name character varying(255) NULL, + last_name character varying(255) NULL +); + +CREATE TABLE public.profile +( + profile_id int PRIMARY KEY references person (id), + email character varying(255) null, + created_at timestamp without time zone NOT NULL DEFAULT now() +); + +CREATE TABLE public.movie_person +( + id int generated by default as identity, + movie_id int references movie (id), + person_id int references person (id), + primary key (id, movie_id, person_id) +); + +insert into "public"."movie" ("created_at", "id", "name") +values ('2022-08-20 00:29:45.400188', 1, 'Top Gun: Maverick'); +insert into "public"."movie" ("created_at", "id", "name") +values ('2022-08-22 00:29:45.400188', 2, 'Mad Max: Fury Road'); +insert into "public"."movie" ("created_at", "id", "name") +values ('2022-08-28 00:29:45.400188', 3, 'Guns Away'); + + +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:02.120528', 'Tom', 1, 'Cruise'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:02.120528', 'Tom', 2, 'Holland'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:33.72443', 'Bob', 3, 'Saggett'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:33.72443', 'Random', 4, 'Actor'); + + +insert into "public"."profile" ("created_at", "email", "profile_id") +values ('2022-08-20 00:30:33.72443', 'tom.cruise@supabase.io', 1); +insert into "public"."profile" ("created_at", "email", "profile_id") +values ('2022-08-20 00:30:33.72443', 'tom.holland@supabase.io', 2); +insert into "public"."profile" ("created_at", "email", "profile_id") +values ('2022-08-20 00:30:33.72443', 'bob.saggett@supabase.io', 3); + +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (1, 1, 1); +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (2, 2, 2); +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (3, 1, 3); +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (4, 3, 4); + + +-- STORED FUNCTION +CREATE FUNCTION public.get_status(name_param text) + RETURNS user_status AS +$$ +SELECT status +from users +WHERE username = name_param; +$$ + LANGUAGE SQL IMMUTABLE; + +-- SECOND SCHEMA USERS +CREATE TYPE personal.user_status AS ENUM ('ONLINE', 'OFFLINE'); +CREATE TABLE personal.users +( + username text primary key, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + data jsonb DEFAULT null, + age_range int4range DEFAULT null, + status user_status DEFAULT 'ONLINE':: public.user_status +); + +-- SECOND SCHEMA STORED FUNCTION +CREATE FUNCTION personal.get_status(name_param text) + RETURNS user_status AS +$$ +SELECT status +from users +WHERE username = name_param; +$$ + LANGUAGE SQL IMMUTABLE; \ No newline at end of file From c095013d0bdaf33013de83648b976389b1f7c6e2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Jan 2025 11:58:40 -0300 Subject: [PATCH 5/8] Fix tests --- RealtimeTests/ChannelTests.cs | 8 ++++---- RealtimeTests/ClientTests.cs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/RealtimeTests/ChannelTests.cs b/RealtimeTests/ChannelTests.cs index f78b34b..a6f4349 100644 --- a/RealtimeTests/ChannelTests.cs +++ b/RealtimeTests/ChannelTests.cs @@ -60,13 +60,13 @@ public async Task ChannelSupportsWalrusArray() var channel = _socketClient!.Channel("realtime:public:todos"); var numbers = new List { 4, 5, 6 }; - await channel.Subscribe(); - - channel.AddPostgresChangeHandler(ListenType.Inserts, (_, changes) => + channel.OnPostgresChange((_, changes) => { result = changes.Model(); tsc.SetResult(true); - }); + }, ListenType.Inserts); + + await channel.Subscribe(); await _restClient!.Table().Insert(new Todo { UserId = 1, Numbers = numbers }); diff --git a/RealtimeTests/ClientTests.cs b/RealtimeTests/ClientTests.cs index 83bb30c..c4564ff 100644 --- a/RealtimeTests/ClientTests.cs +++ b/RealtimeTests/ClientTests.cs @@ -103,8 +103,8 @@ public async Task ClientCanRemoveChannelSubscription() [TestMethod("Client: SetsAuth")] public async Task ClientSetsAuth() { - var channel = client!.Channel("realtime", "public", "todos"); - var channel2 = client!.Channel("realtime", "public", "todos"); + var channel = client!.Channel("realtime:public:todos"); + var channel2 = client!.Channel("realtime:public:todos"); var token = @"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.C8oVtF5DICct_4HcdSKt8pdrxBFMQOAnPpbiiUbaXAY"; From b8b20bddf65450e7169c203002682bb80e7cebe1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Jan 2025 12:00:35 -0300 Subject: [PATCH 6/8] fix ci --- .github/workflows/build-and-test.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 0eb7767..dfd98d4 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,13 +23,12 @@ jobs: - name: Build run: dotnet build --configuration Release --no-restore - #- name: Add hosts entries - # run: | - # echo "127.0.0.1 realtime-dev.localhost" | sudo tee -a /etc/hosts - # echo "172.17.0.1 host.docker.internal" | sudo tee -a /etc/hosts + - uses: supabase/setup-cli@v1 + with: + version: latest - #- name: Initialize Testing Stack - # run: docker-compose up -d + - name: Start Supabsae + run: supabase start - #- name: Test - # run: dotnet test --no-restore + - name: Test + run: dotnet test --no-restore From 94bca87507b69d17ec2fbbb611ed07c69004383c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Jan 2025 12:02:26 -0300 Subject: [PATCH 7/8] revert dotnet version to 8 --- RealtimeTests/RealtimeTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RealtimeTests/RealtimeTests.csproj b/RealtimeTests/RealtimeTests.csproj index ffa86bd..9eead2c 100644 --- a/RealtimeTests/RealtimeTests.csproj +++ b/RealtimeTests/RealtimeTests.csproj @@ -1,7 +1,7 @@  false - net9.0 + net8.0 enable latest CS8600;CS8602;CS8603 From a32de0fa695dcac5632bdef1e5aec8c5acd1b4d0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 28 Jan 2025 09:12:27 -0300 Subject: [PATCH 8/8] implement test for multiple handlers --- Realtime/Interfaces/IRealtimeChannel.cs | 11 +++ Realtime/RealtimeChannel.cs | 2 +- RealtimeTests/ChannelPostgresChangesTests.cs | 77 +++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/Realtime/Interfaces/IRealtimeChannel.cs b/Realtime/Interfaces/IRealtimeChannel.cs index f6d5513..1ece754 100644 --- a/Realtime/Interfaces/IRealtimeChannel.cs +++ b/Realtime/Interfaces/IRealtimeChannel.cs @@ -97,6 +97,17 @@ public interface IRealtimeChannel /// string Topic { get; } + /// + /// Registers and adds a postgres change handler. + /// + /// The handler to process the event. + /// The type of event this callback should process. + /// The schema to listen to. + /// The table to listen to. + /// The filter to apply. + /// + public IRealtimeChannel OnPostgresChange(PostgresChangesHandler postgresChangeHandler, ListenType listenType = ListenType.All, string schema = "public", string? table = null, string? filter = null); + /// /// Add a state changed listener /// diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index 6526487..f394c38 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -332,7 +332,7 @@ private void NotifyMessageReceived(SocketResponse message) /// The table to listen to. /// The filter to apply. /// - public RealtimeChannel OnPostgresChange(PostgresChangesHandler postgresChangeHandler, ListenType listenType = ListenType.All, string schema = "public", string? table = null, string? filter = null) + public IRealtimeChannel OnPostgresChange(PostgresChangesHandler postgresChangeHandler, ListenType listenType = ListenType.All, string schema = "public", string? table = null, string? filter = null) { var postgresChangesOptions = new PostgresChangesOptions(schema, table, listenType, filter); Register(postgresChangesOptions); diff --git a/RealtimeTests/ChannelPostgresChangesTests.cs b/RealtimeTests/ChannelPostgresChangesTests.cs index f611f4b..5eb3318 100644 --- a/RealtimeTests/ChannelPostgresChangesTests.cs +++ b/RealtimeTests/ChannelPostgresChangesTests.cs @@ -7,7 +7,6 @@ using RealtimeTests.Models; using Supabase.Realtime; using Supabase.Realtime.Interfaces; -using Supabase.Realtime.PostgresChanges; using static Supabase.Realtime.Constants; using static Supabase.Realtime.PostgresChanges.PostgresChangesOptions; @@ -166,4 +165,80 @@ public async Task ChannelReceivesWildcardCallback() Assert.IsTrue(updateTsc.Task.Result); Assert.IsTrue(deleteTsc.Task.Result); } + + [TestMethod("Channel: Receives Multiple Handlers")] + public async Task ChannelReceivesMultipleHandlers() + { + var insertTsc = new TaskCompletionSource(); + var updateTsc = new TaskCompletionSource(); + var deleteTsc = new TaskCompletionSource(); + var allHandlerTsc = new TaskCompletionSource(); + var filterHandlerTsc = new TaskCompletionSource(); + + var insertHandlerCalledCount = 0; + var updateHandlerCalledCount = 0; + var deleteHandlerCalledCount = 0; + var allHandlerCalledCount = 0; + var filterHandlerCalledCount = 0; + + var channel = _socketClient!.Channel("realtime:public:todos"); + + channel.OnPostgresChange((_, changes) => + { + if (changes.Payload?.Data?.Type == EventType.Insert) + { + insertHandlerCalledCount += 1; + insertTsc.SetResult(true); + } + }, ListenType.Inserts, table: "todos"); + + channel.OnPostgresChange((_, changes) => + { + if (changes.Payload?.Data?.Type == EventType.Update) + { + updateHandlerCalledCount += 1; + updateTsc.SetResult(true); + } + }, ListenType.Updates, table: "todos"); + + channel.OnPostgresChange((_, changes) => + { + if (changes.Payload?.Data?.Type == EventType.Delete) + { + deleteHandlerCalledCount += 1; + deleteTsc.SetResult(true); + } + }, ListenType.Deletes, table: "todos"); + + channel.OnPostgresChange((_, _) => + { + allHandlerCalledCount += 1; + allHandlerTsc.SetResult(true); + }, ListenType.All, table: "todos"); + + channel.OnPostgresChange((_, changes) => + { + filterHandlerCalledCount += 1; + filterHandlerTsc.SetResult(true); + }, ListenType.Updates, table: "todos"); + + await channel.Subscribe(); + + var modeledResponse = await _restClient!.Table().Insert(new Todo + { UserId = 1, Details = "Testing multiple handlers" }); + var newModel = modeledResponse.Models.First(); + + await _restClient.Table().Set(x => x.Details!, "Filtered update").Match(newModel).Update(); + await _restClient.Table().Set(x => x.Details!, "Another update").Match(newModel).Update(); + await _restClient.Table().Match(newModel).Delete(); + + await Task.WhenAll(insertTsc.Task, updateTsc.Task, deleteTsc.Task, allHandlerTsc.Task, filterHandlerTsc.Task); + + Assert.AreEqual(insertHandlerCalledCount, 1); + Assert.AreEqual(updateHandlerCalledCount, 2); + Assert.AreEqual(deleteHandlerCalledCount, 1); + + Assert.AreEqual(allHandlerCalledCount, 4); + Assert.AreEqual(filterHandlerCalledCount, 1); + } } \ No newline at end of file