diff --git a/.gitignore b/.gitignore index 979f53fef92d6..4105694af0a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,9 @@ typings/ # TypeScript cache *.tsbuildinfo +# Next.js declaration files (auto-generated) +next-env.d.ts + # Optional npm cache directory .npm diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 444ad2331d317..53fb20900455a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1659,8 +1659,8 @@ export const realtime: NavMenuConstant = { url: '/guides/realtime', }, { - name: 'Concepts', - url: '/guides/realtime/concepts', + name: 'Getting Started', + url: '/guides/realtime/getting_started', }, { name: 'Usage', @@ -1713,6 +1713,7 @@ export const realtime: NavMenuConstant = { enabled: billingEnabled, }, { name: 'Architecture', url: '/guides/realtime/architecture' }, + { name: 'Concepts', url: '/guides/realtime/concepts' }, { name: 'Protocol', url: '/guides/realtime/protocol', items: [] }, { name: 'Benchmarks', url: '/guides/realtime/benchmarks' }, ], diff --git a/apps/docs/content/guides/realtime.mdx b/apps/docs/content/guides/realtime.mdx index 831f40e724add..8333973ae784a 100644 --- a/apps/docs/content/guides/realtime.mdx +++ b/apps/docs/content/guides/realtime.mdx @@ -8,9 +8,19 @@ hideToc: true Supabase provides a globally distributed [Realtime](https://github.com/supabase/realtime) service with the following features: -- [Broadcast](/docs/guides/realtime/broadcast): Send low-latency messages using the client libraries, REST, or your Database. -- [Presence](/docs/guides/realtime/presence): Track and synchronize shared state between users. -- [Postgres Changes](/docs/guides/realtime/postgres-changes): Listen to Database changes and send them to authorized users. +- [Broadcast](/docs/guides/realtime/broadcast): Send low-latency messages between clients. Perfect for real-time messaging, database changes, cursor tracking, game events, and custom notifications. +- [Presence](/docs/guides/realtime/presence): Track and synchronize user state across clients. Ideal for showing who's online, or active participants. +- [Postgres Changes](/docs/guides/realtime/postgres-changes): Listen to database changes in real-time. + +## What can you build? + +- **Chat applications** - Real-time messaging with typing indicators and online presence +- **Collaborative tools** - Document editing, whiteboards, and shared workspaces +- **Live dashboards** - Real-time data visualization and monitoring +- **Multiplayer games** - Synchronized game state and player interactions +- **Social features** - Live notifications, reactions, and user activity feeds + +Check the [Getting Started](/docs/guides/realtime/getting-started) guide to get started. ## Examples diff --git a/apps/docs/content/guides/realtime/concepts.mdx b/apps/docs/content/guides/realtime/concepts.mdx index a5e9de10c6240..13595d4691c92 100644 --- a/apps/docs/content/guides/realtime/concepts.mdx +++ b/apps/docs/content/guides/realtime/concepts.mdx @@ -1,116 +1,92 @@ --- -id: 'channels' +id: 'concepts' title: 'Realtime Concepts' -description: 'Learn about Channels and other extensions in Supabase Realtime' -subtitle: 'Learn about Channels and other extensions in Supabase Realtime' -sidebar_label: 'Concepts' +description: 'Useful concepts to understand Realtime and how it works' --- -You can use Supabase Realtime to build real-time applications with collaborative/multiplayer functionality. It includes 3 core extensions: +## Concepts -- [Broadcast](/docs/guides/realtime/broadcast): sends rapid, ephemeral messages to other connected clients. You can use it to track mouse movements, for example. -- [Presence](/docs/guides/realtime/presence): sends user state between connected clients. You can use it to show an "online" status, which disappears when a user is disconnected. -- [Postgres Changes](/docs/guides/realtime/postgres-changes): receives database changes in real-time. +There are several concepts and terminology that is useful to understand how Realtime works. -## Channels - -A Channel is the basic building block of Realtime. You can think of a Channel as a chatroom, similar to a Discord or Slack channel, where participants are able to see who's online and send and receive messages. - -When you initialize your Supabase Realtime client, you define a `topic` that uniquely references a channel. Clients can bi-directionally send and receive messages over a Channel. - -```js -import { createClient } from '@supabase/supabase-js' +- **Channels**: the foundation of Realtime. Think of them as rooms where clients can communicate and listen to events. Channels are identified by a topic name and if they are public or private. +- **Topics**: the name of the channel. They are used to identify the channel and are a string used to identify the channel. +- **Events**: the type of messages that can be sent and received. +- **Payload**: the actual data that is sent and received and that the user will act upon. +- **Concurrent Connections**: number of total channels subscribed for all clients. -const supabase = createClient('https://.supabase.co', '') - -const roomOne = supabase.channel('room-one') // set your topic here -``` - -## Authorization +## Channels -Authorization is done via RLS policies against the table `realtime.messages` which will determine if a user can connect to a Channel and if they are allowed to send messages to a Channel. +Channels are the foundation of Realtime. Think of them as rooms where clients can communicate and listen to events. Channels are identified by a topic name and if they are public or private. -By default, channels are public and you need to set that you want to use a private channel. +For private channels, you need to use [Realtime Authorization](/docs/guides/realtime/authorization) to control access to the channel and if they are able to send messages. +For public channels, any user can subscribe to the channel, send and receive messages. -```js -import { createClient } from '@supabase/supabase-js' -const supabase = createClient('https://.supabase.co', '') -const roomOne = supabase.channel('private-room-one', { config: { private: true } }) -``` +You can set your project to use only private channels or both private and public channels in the [Realtime Settings](/docs/guides/realtime/settings). -## Broadcast + -Realtime Broadcast follows the [publish-subscribe pattern](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) where a client publishes messages to a channel based on a unique topic. For example, a user could send a message to a channel with topic `room-one`. +If you have a private channel and a public channel with the same topic name, Realtime sees them as unique channels and won't send messages between them. -```js -import { createClient } from '@supabase/supabase-js' -const supabase = createClient('https://.supabase.co', '') -const roomOne = supabase.channel('room-one') // set your topic here + -// ---cut--- -roomOne.send({ - type: 'broadcast', - event: 'test', - payload: { message: 'hello, world' }, -}) -``` +## Database resources -Other clients can receive the message in real-time by subscribing to the Channel with topic `room-one`. These clients continue to receive messages as long as they are subscribed and connected to the same Channel topic. +Realtime uses several database connections to perform several operations. As a user, you are able to tune some of them using [Realtime Settings](/docs/guides/realtime/settings). -You can also use [Broadcast using the REST API](/docs/guides/realtime/broadcast#broadcast-using-the-rest-api) and [Broadcast using the Database](/docs/guides/realtime/broadcast#broadcast-using-the-database) to send messages to a Channel which allows you to do more advanced use-cases. +### Database connections -An example use-case is sharing a user's cursor position with other clients in an online game. +Realtime uses several database connections to do several operations. Some of them, as a user, you are able to tune them. -## Presence +The connections are: -Presence can be used to share an individual's state with others within a Channel. +- **Migrations**: Two temporary connections to run database migrations when needed +- **Authorization**: Configurable connection pool to check authorization policies on join +- **Postgres Changes**: 3 connection pools required + - **Subscription management**: To manage the subscribers to Postgres Changes + - **Subscription cleanup**: To cleanup the subscribers to Postgres Changes + - **WAL pull**: To pull the changes from the database -```js -import { createClient } from '@supabase/supabase-js' -const supabase = createClient('https://.supabase.co', '') -const roomOne = supabase.channel('room-one') // set your topic here +The number of connections varies based on the instance size and your configuration in [Realtime Settings](/docs/guides/realtime/settings). -// ---cut--- -const presenceTrackStatus = await roomOne.track({ - user: 'user-1', - online_at: new Date().toISOString(), -}) -``` +### Replication slots -{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} -Each client maintains their own state, and this is then combined into a "shared state" for that Channel topic. It's commonly used for sharing statuses (e.g. "online" or "inactive"). The neat thing about Presence is that if a client is suddenly disconnected (for example, they go offline), their state is automatically removed from the shared state. If you've ever tried to build an “I'm online” feature which handles unexpected disconnects, you'll appreciate how useful this is. -When a new client subscribes to a channel, it will immediately receive the channel's latest state in a single message because the state is held by the Realtime server. +Realtime also uses, at maximum, 2 replication slots. -## Postgres Changes +- **Broadcast from database**: To broadcast the changes from the database to the clients +- **Postgres Changes**: To listen to changes from the database -The Postgres Changes extension listens for database changes and sends them to clients. Clients are required to subscribe with a JWT dictating which changes they are allowed to receive based on the database's [Row Level Security](/docs/guides/database/postgres/row-level-security). +### Schema and tables -```js -import { createClient } from '@supabase/supabase-js' -const supabase = createClient('your_project_url', 'your_supabase_api_key') +The `realtime` schema creates the following tables: -// ---cut--- -const allChanges = supabase - .channel('schema-db-changes') - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - }, - (payload) => console.log(payload) - ) - .subscribe() -``` +- `schema_migrations` - To track the migrations that have been run on the database from Realtime +- `subscription` - Track the subscribers to Postgres Changes +- `messages` - Partitioned table per day that's used for Authorization and Broadcast from database + - **Authorization**: To check the authorization policies on join by checking if a given user can read and write to this table + - **Broadcast from database**: Replication slot tracks a publication to this table to broadcast the changes to the connected clients. + - The schema from the table is the following: + ```sql + create table realtime.messages ( + topic text not null, -- The topic of the message + extension text not null, -- The extension of the message (presence, broadcast) + payload jsonb null, -- The payload of the message + event text null, -- The event of the message + private boolean null default false, -- If the message is going to use a private channel + updated_at timestamp without time zone not null default now(), -- The timestamp of the message + inserted_at timestamp without time zone not null default now(), -- The timestamp of the message + id uuid not null default gen_random_uuid (), -- The id of the message + constraint messages_pkey primary key (id, inserted_at)) partition by RANGE (inserted_at); + ``` -Anyone with access to a valid JWT signed with the project's JWT secret is able to listen to your database's changes, unless tables have [Row Level Security](/docs/guides/database/postgres/row-level-security) enabled and policies in place. + -Clients can choose to receive `INSERT`, `UPDATE`, `DELETE`, or `*` (all) changes for all changes in a schema, a table in a schema, or a column's value in a table. Your clients should only listen to tables in the `public` schema and you must first enable the tables you want your clients to listen to. +Realtime has a cleanup process that will delete tables older than 3 days. -## Choosing between Broadcast and Postgres Changes for database changes + -We recommend using Broadcast by default and using Broadcast from Database specifically as it will allow you to scale your application compared to Postgres Changes. +### Functions -## Choosing between Broadcast and Presence +Realtime creates two functions on your database: -We recommend using Broadcast by default, and then Presence when required. Presence utilizes an in-memory conflict-free replicated data type (CRDT) to track and synchronize shared state in an eventually consistent manner. It computes the difference between existing state and new state changes and sends the necessary updates to clients via Broadcast. This is computationally heavy, so you should use it sparingly. If you use Presence, it's best to throttle your changes so that you are sending updates less frequently. +- `realtime.send` - Inserts an entry into `realtime.messages` table that will trigger the replication slot to broadcast the changes to the clients. It also captures errors to prevent the trigger from breaking. +- `realtime.broadcast_changes` - uses `realtime.send` to broadcast the changes with a format that is compatible with Postgres Changes diff --git a/apps/docs/content/guides/realtime/getting_started.mdx b/apps/docs/content/guides/realtime/getting_started.mdx new file mode 100644 index 0000000000000..43fe9294953d2 --- /dev/null +++ b/apps/docs/content/guides/realtime/getting_started.mdx @@ -0,0 +1,667 @@ +--- +id: 'getting-started' +title: 'Getting Started with Realtime' +description: 'Learn how to build real-time applications with Supabase Realtime' +subtitle: 'Learn how to build real-time applications with Supabase Realtime' +sidebar_label: 'Getting Started' +--- + +## Quick start + +### 1. Install the client library + + + + +```bash +npm install @supabase/supabase-js +``` + + + + +```bash +flutter pub add supabase_flutter +``` + + + + +```swift +let package = Package( + // ... + dependencies: [ + // ... + .package( + url: "https://github.com/supabase/supabase-swift.git", + from: "2.0.0" + ), + ], + targets: [ + .target( + name: "YourTargetName", + dependencies: [ + .product( + name: "Supabase", + package: "supabase-swift" + ), + ] + ) + ] +) +``` + + + + +```bash +pip install supabase +``` + + + + +```bash +conda install -c conda-forge supabase +``` + + + + +### 2. Initialize the client + +Get your project URL and key. +<$Partial path="api_settings.mdx" /> + + + + +```ts +import { createClient } from '@supabase/supabase-js' + +const supabase = createClient('https://.supabase.co', '') +``` + + + + +```dart +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() async { + await Supabase.initialize( + url: 'https://.supabase.co', + anonKey: '', + ); + runApp(MyApp()); +} + +final supabase = Supabase.instance.client; +``` + + + + +```swift +import Supabase + +let supabase = SupabaseClient( + supabaseURL: URL(string: "https://.supabase.co")!, + supabaseKey: "" +) +``` + + + + +```python +from supabase import create_client, Client + +url: str = "https://.supabase.co" +key: str = "" +supabase: Client = create_client(url, key) +``` + + + + +### 3. Create your first Channel + +Channels are the foundation of Realtime. Think of them as rooms where clients can communicate. Each channel is identified by a topic name and if they are public or private. + + + + +```ts +// Create a channel with a descriptive topic name +const channel = supabase.channel('room:lobby:messages', { + config: { private: true }, // Recommended for production +}) +``` + + + + +```dart +// Create a channel with a descriptive topic name +final channel = supabase.channel('room:lobby:messages'); +``` + + + + +```swift +// Create a channel with a descriptive topic name +let channel = supabase.channel("room:lobby:messages") { + $0.isPrivate = true +} +``` + + + + +```python +# Create a channel with a descriptive topic name +channel = supabase.channel('room:lobby:messages', params={config={private= True }}) +``` + + + + +### 4. Set up authorization + +Since we're using a private channel, you need to create a basic RLS policy on the `realtime.messages` table to allow authenticated users to connect. Row Level Security (RLS) policies control who can access your Realtime channels based on user authentication and custom rules: + +```sql +-- Allow authenticated users to receive broadcasts +CREATE POLICY "authenticated_users_can_receive" ON realtime.messages + FOR SELECT TO authenticated USING (true); + +-- Allow authenticated users to send broadcasts +CREATE POLICY "authenticated_users_can_send" ON realtime.messages + FOR INSERT TO authenticated WITH CHECK (true); +``` + +### 5. Send and receive messages + +There are three main ways to send messages with Realtime: + +#### 5.1 using client libraries + +Send and receive messages using the Supabase client: + + + + +```ts +// Listen for messages +channel + .on('broadcast', { event: 'message_sent' }, (payload: { payload: any }) => { + console.log('New message:', payload.payload) + }) + .subscribe() + +// Send a message +channel.send({ + type: 'broadcast', + event: 'message_sent', + payload: { + text: 'Hello, world!', + user: 'john_doe', + timestamp: new Date().toISOString(), + }, +}) +``` + + + + +```dart +// Listen for messages +channel.onBroadcast( + event: 'message_sent', + callback: (payload) { + print('New message: ${payload['payload']}'); + }, +).subscribe(); + +// Send a message +channel.sendBroadcastMessage( + event: 'message_sent', + payload: { + 'text': 'Hello, world!', + 'user': 'john_doe', + 'timestamp': DateTime.now().toIso8601String(), + }, +); +``` + + + + +```swift +// Listen for messages +await channel.onBroadcast(event: "message_sent") { message in + print("New message: \(message.payload)") +} + +let status = await channel.subscribe() + +// Send a message +await channel.sendBroadcastMessage( + event: "message_sent", + payload: [ + "text": "Hello, world!", + "user": "john_doe", + "timestamp": ISO8601DateFormatter().string(from: Date()) + ] +) +``` + + + + +```python +# Listen for messages +def message_handler(payload): + print(f"New message: {payload['payload']}") + +channel.on_broadcast(event="message_sent", callback=message_handler).subscribe() + +# Send a message +channel.send_broadcast_message( + event="message_sent", + payload={ + "text": "Hello, world!", + "user": "john_doe", + "timestamp": datetime.now().isoformat() + } +) +``` + + + + +#### 5.2 using HTTP/REST API + +Send messages via HTTP requests, perfect for server-side applications: + + + + +```ts +// Send message via REST API +const response = await fetch(`https://.supabase.co/rest/v1/rpc/broadcast`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer `, + apikey: '', + }, + body: JSON.stringify({ + topic: 'room:lobby:messages', + event: 'message_sent', + payload: { + text: 'Hello from server!', + user: 'system', + timestamp: new Date().toISOString(), + }, + private: true, + }), +}) +``` + + + + +```dart +import 'package:http/http.dart' as http; +import 'dart:convert'; + +// Send message via REST API +final response = await http.post( + Uri.parse('https://.supabase.co/rest/v1/rpc/broadcast'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ', + 'apikey': '', + }, + body: jsonEncode({ + 'topic': 'room:lobby:messages', + 'event': 'message_sent', + 'payload': { + 'text': 'Hello from server!', + 'user': 'system', + 'timestamp': DateTime.now().toIso8601String(), + }, + 'private': true, + }), +); +``` + + + + +```swift +import Foundation + +// Send message via REST API +let url = URL(string: "https://.supabase.co/rest/v1/rpc/broadcast")! +var request = URLRequest(url: url) +request.httpMethod = "POST" +request.setValue("application/json", forHTTPHeaderField: "Content-Type") +request.setValue("Bearer ", forHTTPHeaderField: "Authorization") +request.setValue("", forHTTPHeaderField: "apikey") + +let payload = [ + "topic": "room:lobby:messages", + "event": "message_sent", + "payload": [ + "text": "Hello from server!", + "user": "system", + "timestamp": ISO8601DateFormatter().string(from: Date()) + ], + "private": true +] as [String: Any] + +request.httpBody = try JSONSerialization.data(withJSONObject: payload) + +let (data, response) = try await URLSession.shared.data(for: request) +``` + + + + +```python +import requests +from datetime import datetime + +# Send message via REST API +response = requests.post( + 'https://.supabase.co/rest/v1/rpc/broadcast', + headers={ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ', + 'apikey': '' + }, + json={ + 'topic': 'room:lobby:messages', + 'event': 'message_sent', + 'payload': { + 'text': 'Hello from server!', + 'user': 'system', + 'timestamp': datetime.now().isoformat() + }, + 'private': True + } +) +``` + + + + +#### 5.3 using database triggers + +Automatically broadcast database changes using triggers. Choose the approach that best fits your needs: + +**Using `realtime.broadcast_changes` (Best for mirroring database changes)** + +```sql +-- Create a trigger function for broadcasting database changes +CREATE OR REPLACE FUNCTION broadcast_message_changes() +RETURNS TRIGGER AS $$ +BEGIN + -- Broadcast to room-specific channel + PERFORM realtime.broadcast_changes( + 'room:' || NEW.room_id::text || ':messages', + TG_OP, + TG_OP, + TG_TABLE_NAME, + TG_TABLE_SCHEMA, + NEW, + OLD + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Apply trigger to your messages table +CREATE TRIGGER messages_broadcast_trigger + AFTER INSERT OR UPDATE OR DELETE ON messages + FOR EACH ROW EXECUTE FUNCTION broadcast_message_changes(); +``` + +**Using `realtime.send` (Best for custom notifications and filtered data)** + +```sql +-- Create a trigger function for custom notifications +CREATE OR REPLACE FUNCTION notify_message_activity() +RETURNS TRIGGER AS $$ +BEGIN + -- Send custom notification when new message is created + IF TG_OP = 'INSERT' THEN + PERFORM realtime.send( + 'room:' || NEW.room_id::text || ':notifications', + 'message_created', + jsonb_build_object( + 'message_id', NEW.id, + 'user_id', NEW.user_id, + 'room_id', NEW.room_id, + 'created_at', NEW.created_at + ), + true -- private channel + ); + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Apply trigger to your messages table +CREATE TRIGGER messages_notification_trigger + AFTER INSERT ON messages + FOR EACH ROW EXECUTE FUNCTION notify_message_activity(); +``` + +- **`realtime.broadcast_changes`** sends the full database change with metadata +- **`realtime.send`** allows you to send custom payloads and control exactly what data is broadcast + +## Essential best practices + +### Use private channels + +Always use private channels for production applications to ensure proper security and authorization: + +```ts +const channel = supabase.channel('room:123:messages', { + config: { private: true }, +}) +``` + +### Follow naming conventions + +**Channel Topics:** Use the pattern `scope:id:entity` + +- `room:123:messages` - Messages in room 123 +- `game:456:moves` - Game moves for game 456 +- `user:789:notifications` - Notifications for user 789 + +### Clean up subscriptions + +Always unsubscribe when you are done with a channel to ensure you free up resources: + + + + +```ts +// React example +import { useEffect } from 'react' + +useEffect(() => { + const channel = supabase.channel('room:123:messages') + + return () => { + supabase.removeChannel(channel) + } +}, []) +``` + + + + +```dart +// Flutter example +class _MyWidgetState extends State { + RealtimeChannel? _channel; + + @override + void initState() { + super.initState(); + _channel = supabase.channel('room:123:messages'); + } + + @override + void dispose() { + _channel?.unsubscribe(); + super.dispose(); + } +} +``` + + + + +```swift +// SwiftUI example +struct ContentView: View { + @State private var channel: RealtimeChannelV2? + + var body: some View { + // Your UI here + .onAppear { + channel = supabase.realtimeV2.channel("room:123:messages") + } + .onDisappear { + Task { + await channel?.unsubscribe() + } + } + } +} +``` + + + + +```python +# Python example with context manager +class RealtimeManager: + def __init__(self): + self.channel = None + + def __enter__(self): + self.channel = supabase.channel('room:123:messages') + return self.channel + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.channel: + self.channel.unsubscribe() + +# Usage +with RealtimeManager() as channel: + # Use channel here + pass +``` + + + + +## Choose the right feature + +### When to use Broadcast + +- Real-time messaging and notifications +- Custom events and game state +- Database change notifications (with triggers) +- High-frequency updates (e.g. Cursor tracking) +- Most use cases + +### When to use Presence + +- User online/offline status +- Active user counters +- Use minimally due to computational overhead + +### When to use Postgres Changes + +- Quick testing and development +- Low amount of connected users + +## Next steps + +Now that you understand the basics, dive deeper into each feature: + +### Core features + +- **[Broadcast](/docs/guides/realtime/broadcast)** - Learn about sending messages, database triggers, and REST API usage +- **[Presence](/docs/guides/realtime/presence)** - Implement user state tracking and online indicators +- **[Postgres Changes](/docs/guides/realtime/postgres-changes)** - Understanding database change listeners (consider migrating to Broadcast) + +### Security & configuration + +- **[Authorization](/docs/guides/realtime/authorization)** - Set up RLS policies for private channels +- **[Settings](/docs/guides/realtime/settings)** - Configure your Realtime instance for optimal performance + +### Advanced topics + +- **[Architecture](/docs/guides/realtime/architecture)** - Understand how Realtime works under the hood +- **[Benchmarks](/docs/guides/realtime/benchmarks)** - Performance characteristics and scaling considerations +- **[Quotas](/docs/guides/realtime/quotas)** - Usage limits and best practices + +### Integration guides + +- **[Realtime with Next.js](/docs/guides/realtime/realtime-with-nextjs)** - Build real-time Next.js applications +- **[User Presence](/docs/guides/realtime/realtime-user-presence)** - Implement user presence features +- **[Database Changes](/docs/guides/realtime/subscribing-to-database-changes)** - Listen to database changes + +### Framework examples + +- **[Flutter Integration](/docs/guides/realtime/realtime-listening-flutter)** - Build real-time Flutter applications + +Ready to build something amazing? Start with the [Broadcast guide](/docs/guides/realtime/broadcast) to create your first real-time feature! diff --git a/apps/docs/content/guides/realtime/protocol.mdx b/apps/docs/content/guides/realtime/protocol.mdx index a60ecf111ba70..9708c58683ad7 100644 --- a/apps/docs/content/guides/realtime/protocol.mdx +++ b/apps/docs/content/guides/realtime/protocol.mdx @@ -191,7 +191,7 @@ System messages are sent by the server to inform the client about the status of #### Payload of heartbeat -The heartbeat message should be sent at least every 30 seconds to avoid a connection timeout. Payload should be empty object. +The heartbeat message should be sent at least every 25 seconds to avoid a connection timeout. Payload should be empty object. #### Payload of access_token diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts deleted file mode 100644 index 1b3be0840f3f6..0000000000000 --- a/apps/docs/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/docs/package.json b/apps/docs/package.json index 5f9eb55ac14f2..793c1b69f1378 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -31,7 +31,7 @@ "preinstall": "npx only-allow pnpm", "presync": "pnpm run codegen:graphql", "pretest": "pnpm run codegen:examples", - "pretypecheck": "pnpm run codegen:graphql", + "pretypecheck": "pnpm run codegen:graphql && next typegen", "start": "next start", "sync": "tsx --conditions=react-server ./resources/rootSync.ts", "test": "pnpm supabase start && pnpm run test:local && pnpm supabase stop", diff --git a/apps/docs/resources/globalSearch/globalSearchModel.ts b/apps/docs/resources/globalSearch/globalSearchModel.ts index 8b46f5d870721..731d0271e2c72 100644 --- a/apps/docs/resources/globalSearch/globalSearchModel.ts +++ b/apps/docs/resources/globalSearch/globalSearchModel.ts @@ -4,7 +4,7 @@ import { Result } from '~/features/helpers.fn' import { openAI } from '~/lib/openAi' import { supabase, type DatabaseCorrected } from '~/lib/supabase' -import { isFeatureEnabled } from 'common/enabled-features' +import { isFeatureEnabled } from '../../../../packages/common/enabled-features' import { GuideModel } from '../guide/guideModel' import { DB_METADATA_TAG_PLATFORM_CLI, diff --git a/apps/docs/scripts/search/generate-embeddings.ts b/apps/docs/scripts/search/generate-embeddings.ts index a5abac64019b8..81263f113a5b7 100644 --- a/apps/docs/scripts/search/generate-embeddings.ts +++ b/apps/docs/scripts/search/generate-embeddings.ts @@ -178,12 +178,14 @@ async function prepareSections( return } - if (existingPage && debug) { - console.log( - !shouldRefresh - ? `[${path}] Docs have changed, removing old page sections and their embeddings` - : `[${path}] Refresh flag set, removing old page sections and their embeddings` - ) + if (existingPage) { + if (debug) { + console.log( + !shouldRefresh + ? `[${path}] Docs have changed, removing old page sections and their embeddings` + : `[${path}] Refresh flag set, removing old page sections and their embeddings` + ) + } const { error: deletePageSectionError } = await supabaseClient .from(pageSectionTable) @@ -254,18 +256,6 @@ async function processAndInsertEmbeddings( allSections: PageSectionForEmbedding[], pageInfoMap: Map ): Promise { - if (allSections.length === 0) { - return { - successfulPages: new Set(), - failedPages: new Set(), - totalSectionsProcessed: 0, - totalSectionsInserted: 0, - } - } - - console.log(`Processing ${allSections.length} sections with embeddings + insertion`) - - const embeddingBatches = createBatches(allSections, CONFIG.OPENAI_BATCH_SIZE) const result: ProcessingResult = { successfulPages: new Set(), failedPages: new Set(), @@ -273,6 +263,14 @@ async function processAndInsertEmbeddings( totalSectionsInserted: 0, } + if (allSections.length === 0) { + return result + } + + console.log(`Processing ${allSections.length} sections with embeddings + insertion`) + + const embeddingBatches = createBatches(allSections, CONFIG.OPENAI_BATCH_SIZE) + // Track sections inserted per page const pageSectionsInserted = new Map() diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index e0bba48dbed78..bae1a6b3b1c48 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -23,7 +23,7 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists/* && \ update-ca-certificates -RUN npm install -g pnpm@9.15.5 +RUN npm install -g pnpm@10.16.1 WORKDIR /app diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 1818e82ebba15..4b5e9142a7e1d 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -5,6 +5,7 @@ import { ref as valtioRef } from 'valtio' import { handleCopyCell } from 'components/grid/SupabaseGrid.utils' import { formatForeignKeys } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils' import AlertError from 'components/ui/AlertError' +import { InlineLink } from 'components/ui/InlineLink' import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' @@ -14,6 +15,7 @@ import { useCsvFileDrop } from 'hooks/ui/useCsvFileDrop' import { useTableEditorStateSnapshot } from 'state/table-editor' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, cn } from 'ui' +import { Admonition } from 'ui-patterns' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import type { Filter, GridProps, SupaRow } from '../../types' import { useOnRowsChange } from './Grid.utils' @@ -58,6 +60,7 @@ export const Grid = memo( const { data: org } = useSelectedOrganizationQuery() const { data: project } = useSelectedProjectQuery() + const isBranch = project?.parent_project_ref !== undefined const onRowsChange = useOnRowsChange(rows) @@ -77,6 +80,9 @@ export const Grid = memo( const isForeignTable = tableEntityType === ENTITY_TYPE.FOREIGN_TABLE const isTableEmpty = (rows ?? []).length === 0 + const isForeignTableMissingVaultKeyError = + isForeignTable && isError && error.message.includes('query vault failed') + const { mutate: sendEvent } = useSendEventMutation() const { isDraggedOver, onDragOver, onFileDrop } = useCsvFileDrop({ @@ -149,20 +155,51 @@ export const Grid = memo( className="absolute top-9 p-2 w-full z-[1] pointer-events-none" > {isLoading && } - {isError && ( - - {filters.length > 0 && ( + {isError ? ( + isForeignTableMissingVaultKeyError ? ( +

- Verify that the filter values are correct, as the error may stem from an - incorrectly applied filter + The key that's used to retrieve data from your foreign table is either + incorrect or missing. Verify the key in your{' '} + + wrapper's settings + {' '} + or in{' '} + + Vault + + .

- )} -
- )} + {isBranch && ( +

+ Note: Vault keys from the main project do not sync to branches. You may add + them manually into{' '} + + Vault + {' '} + if you want to query foreign tables while on a branch. +

+ )} + + ) : ( + + {filters.length > 0 && ( +

+ Verify that the filter values are correct, as the error may stem from an + incorrectly applied filter +

+ )} +
+ ) + ) : null} {isSuccess && ( <> {(filters ?? []).length === 0 ? ( diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx index 1a0eaa02a9ead..a78333698828c 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx @@ -19,7 +19,7 @@ export const APIKeyRow = ({ apiKey: Extract lastSeen?: { timestamp: string } }) => { - const MotionTableRow = motion(TableRow) + const MotionTableRow = motion.create(TableRow) return ( { onOpenAutoFocus={(e) => { if (promptProPlanUpgrade) e.preventDefault() }} + aria-describedby={undefined} > Create a new preview branch diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx index 36b0407e88d6b..10c152d69ee98 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx @@ -21,6 +21,7 @@ import { AccordionItem_Shadcn_, AccordionTrigger_Shadcn_, Button, + DialogSectionSeparator, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -30,7 +31,6 @@ import { SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, - Separator, Sheet, SheetContent, SheetDescription, @@ -40,6 +40,7 @@ import { SheetTitle, TextArea_Shadcn_, } from 'ui' +import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import NewPublicationPanel from './NewPublicationPanel' import PublicationsComboBox from './PublicationsComboBox' @@ -96,10 +97,12 @@ export const DestinationPanel = ({ const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation() - const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({ - projectRef, - sourceId, - }) + const { + data: publications = [], + isLoading: isLoadingPublications, + isSuccess: isSuccessPublications, + refetch: refetchPublications, + } = useReplicationPublicationsQuery({ projectRef, sourceId }) const { data: destinationData } = useReplicationDestinationByIdQuery({ projectRef, @@ -132,11 +135,21 @@ export const DestinationPanel = ({ resolver: zodResolver(FormSchema), defaultValues, }) + const publicationName = form.watch('publicationName') const isSaving = creatingDestinationPipeline || updatingDestinationPipeline || startingPipeline + const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications]) + const isSelectedPublicationMissing = + isSuccessPublications && !!publicationName && !publicationNames.includes(publicationName) + + const isSubmitDisabled = isSaving || isSelectedPublicationMissing + const onSubmit = async (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!sourceId) return console.error('Source id is required') + if (isSelectedPublicationMissing) { + return toast.error('Please select another publication before continuing') + } try { if (editMode && existingDestination) { @@ -236,6 +249,12 @@ export const DestinationPanel = ({ } }, [visible, defaultValues, form]) + useEffect(() => { + if (visible && projectRef && sourceId) { + refetchPublications() + } + }, [visible, projectRef, sourceId, refetchPublications]) + return ( <> @@ -247,59 +266,72 @@ export const DestinationPanel = ({ {editMode ? null : 'Send data to a new destination'} - + +
-
- ( - - - - - - )} - /> - -

What data to send

- + ( + + + + + + )} + /> + + + +
+

What data to send

( pub.name) || []} - loading={loadingPublications} + publications={publicationNames} + loading={isLoadingPublications} field={field} onNewPublicationClick={() => setPublicationPanelVisible(true)} /> + {isSelectedPublicationMissing && ( + +

+ The publication{' '} + {publicationName} was + not found, it may have been renamed or deleted, please select + another one. +

+
+ )}
)} /> +
-

Where to send that data

+ +
+

Where to send that data

( ( ( - + @@ -373,7 +400,7 @@ export const DestinationPanel = ({ />
- +
@@ -443,7 +470,12 @@ export const DestinationPanel = ({ - ) } return ( -
+
{items?.map((entry, index) => )}
diff --git a/apps/studio/components/ui/Charts/useChartHoverState.tsx b/apps/studio/components/ui/Charts/useChartHoverState.tsx index 672706d4a5abe..7c9ad21a9cf02 100644 --- a/apps/studio/components/ui/Charts/useChartHoverState.tsx +++ b/apps/studio/components/ui/Charts/useChartHoverState.tsx @@ -23,14 +23,16 @@ const subscribers = new Set<(state: ChartHoverState) => void>() // Load initial sync settings from localStorage try { - const hoverSyncStored = localStorage.getItem(CHART_HOVER_SYNC_STORAGE_KEY) - const tooltipSyncStored = localStorage.getItem(CHART_TOOLTIP_SYNC_STORAGE_KEY) + if (typeof window !== 'undefined') { + const hoverSyncStored = localStorage.getItem(CHART_HOVER_SYNC_STORAGE_KEY) + const tooltipSyncStored = localStorage.getItem(CHART_TOOLTIP_SYNC_STORAGE_KEY) - if (hoverSyncStored !== null) { - globalState.syncHover = JSON.parse(hoverSyncStored) - } - if (tooltipSyncStored !== null) { - globalState.syncTooltip = JSON.parse(tooltipSyncStored) + if (hoverSyncStored !== null) { + globalState.syncHover = JSON.parse(hoverSyncStored) + } + if (tooltipSyncStored !== null) { + globalState.syncTooltip = JSON.parse(tooltipSyncStored) + } } } catch (error) { console.warn('Failed to load chart sync settings from localStorage:', error) diff --git a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx index 87bb6b27f228f..92f916d1964c0 100644 --- a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx +++ b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx @@ -15,7 +15,6 @@ import { } from 'ui' import { InlineLinkClassName } from './InlineLink' -// More correct version of FallbackProps from react-error-boundary export type FallbackProps = { error: unknown resetErrorBoundary: (...args: any[]) => void @@ -31,6 +30,9 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback ? errorMessage.includes("Failed to execute 'removeChild' on 'Node'") : false + // Get Sentry issue ID from error if available + const sentryIssueId = (!!error && typeof error === 'object' && (error as any).sentryId) ?? '' + const handleClearStorage = () => { try { localStorage.clear() @@ -67,7 +69,6 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback

{errorMessage}

- {isRemoveChildError ? ( @@ -130,11 +131,10 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback )} -