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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/docs/app/api/graphql/tests/searchDocs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { type OpenAIClientInterface } from '~/lib/openAi'
import { ApiError } from '../../utils'
import { POST } from '../route'

const contentEmbeddingMock = vi.fn().mockImplementation(async () => Result.ok([0.1, 0.2, 0.3]))
const contentEmbeddingMock = vi
.fn()
.mockImplementation(async () => Result.ok({ embedding: [0.1, 0.2, 0.3], tokenCount: 10 }))
const openAIMock: OpenAIClientInterface = {
createContentEmbedding: contentEmbeddingMock,
}
Expand Down
12 changes: 11 additions & 1 deletion apps/docs/app/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export class NoDataError<Details extends ObjectOrNever = never> extends ApiError
}
}

export class FileNotFoundError<Details extends ObjectOrNever = never> extends Error {
constructor(
message: string,
error: Error,
public details?: Details
) {
super(`FileNotFound: ${message}`, { cause: error })
}
}

export class MultiError<ErrorType = unknown, Details extends ObjectOrNever = never> extends Error {
constructor(
message: string,
Expand All @@ -79,7 +89,7 @@ export class MultiError<ErrorType = unknown, Details extends ObjectOrNever = nev

appendError(message: string, error: ErrorType): this {
this.message = `${this.message}\n\t${message}`
;((this.cause ?? (this.cause = [])) as Array<ErrorType>).push(error)
;((this.cause ??= []) as Array<ErrorType>).push(error)
return this
}
}
Expand Down
201 changes: 201 additions & 0 deletions apps/docs/content/guides/realtime/broadcast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -691,3 +691,204 @@ You can pass configuration options while initializing the Supabase Client.
</Tabs>

Use this to guarantee that the server has received the message before resolving `channelD.send`'s promise. If the `ack` config is not set to `true` when creating the channel, the promise returned by `channelD.send` will resolve immediately.

### Send messages using REST calls

You can also send a Broadcast message by making an HTTP request to Realtime servers. This is useful when you want to send messages from your server or client without having to first establish a WebSocket connection.

<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="js"
queryGroup="language"
>
<TabPanel id="js" label="JavaScript">
<Admonition type="note">

This is currently available only in the Supabase JavaScript client version 2.37.0 and later.

</Admonition>

```js
const channel = supabase.channel('test-channel')

// No need to subscribe to channel

channel
.send({
type: 'broadcast',
event: 'test',
payload: { message: 'Hi' },
})
.then((resp) => console.log(resp))

// Remember to clean up the channel

supabase.removeChannel(channel)

```

</TabPanel>
<TabPanel id="dart" label="Dart">
```dart
// No need to subscribe to channel

final channel = supabase.channel('test-channel');
final res = await channel.sendBroadcastMessage(
event: "test",
payload: {
'message': 'Hi',
},
);
print(res);
```

</TabPanel>
<TabPanel id="swift" label="Swift">
```swift
let myChannel = await supabase.channel("room-2") {
$0.broadcast.acknowledgeBroadcasts = true
}

// No need to subscribe to channel

await myChannel.broadcast(event: "test", message: ["message": "HI"])
```

</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
```kotlin
val myChannel = supabase.channel("room-2") {
broadcast {
acknowledgeBroadcasts = true
}
}

// No need to subscribe to channel

myChannel.broadcast(event = "test", buildJsonObject {
put("message", "Hi")
})
```

</TabPanel>
<TabPanel id="python" label="Python">
Unsupported in Python yet.
</TabPanel>
</Tabs>

## Trigger broadcast messages from your database

<Admonition type="caution">

This feature is currently in Public Alpha. If you have any issues [submit a support ticket](https://supabase.help).

</Admonition>

### How it works

Broadcast Changes allows you to trigger messages from your database. To achieve it Realtime is directly reading your WAL (Write Append Log) file using a publication against the `realtime.messages` table so whenever a new insert happens a message is sent to connected users.

It uses partitioned tables per day which allows the deletion your previous messages in a performant way by dropping the physical tables of this partitioned table. Tables older than 3 days old are deleted.

Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON packages. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data.

The database broadcast feature provides two functions to help you send messages:

- `realtime.send` will insert a message into realtime.messages without a specific format.
- `realtime.broadcast_changes` will insert a message with the required fields to emit database changes to clients. This helps you set up triggers on your tables to emit changes.

### Broadcasting a message from your database

The `realtime.send` function provides the most flexibility by allowing you to broadcast messages from your database without a specific format. This allows you to use database broadcast for messages that aren't necessarily tied to the shape of a Postgres row change.

```sql
SELECT realtime.send (
to_jsonb ('{}'::text), -- JSONB Payload
'event', -- Event name
'topic', -- Topic
FALSE -- Public / Private flag
);
```

### Broadcast record changes

#### Setup realtime authorization

Realtime Authorization is required and enabled by default. To allow your users to listen to messages from topics, create a RLS (Row Level Security) policy:

```sql
CREATE POLICY "authenticated can receive broadcasts"
ON "realtime"."messages"
FOR SELECT
TO authenticated
USING ( true );

```

See the [Realtime Authorization](/docs/guides/realtime/authorization) docs to learn how to set up more specific policies.

#### Set up trigger function

First, set up a trigger function that uses `realtime.broadcast_changes` to insert an event whenever it is triggered. The event is set up to include data on the schema, table, operation, and field changes that triggered it.

For this example use case, we want to have a topic with the name `topic:<record id>` to which we're going to broadcast events.

```sql
CREATE OR REPLACE FUNCTION public.your_table_changes()
RETURNS trigger
SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'topic:' || NEW.id::text, -- topic
TG_OP, -- event
TG_OP, -- operation
TG_TABLE_NAME, -- table
TG_TABLE_SCHEMA, -- schema
NEW, -- new record
OLD -- old record
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
```

Of note are the Postgres native trigger special variables used:

- `TG_OP` - the operation that triggered the function
- `TG_TABLE_NAME` - the table that caused the trigger
- `TG_TABLE_SCHEMA` - the schema of the table that caused the trigger invocation
- `NEW` - the record after the change
- `OLD` - the record before the change

You can read more about them in this [guide](https://www.postgresql.org/docs/current/plpgsql-trigger.html#PLPGSQL-DML-TRIGGER).

#### Set up trigger

Next, set up a trigger so the function runs whenever your target table has a change.

```sql
CREATE TRIGGER broadcast_changes_for_your_table_trigger
AFTER INSERT OR UPDATE OR DELETE ON public.your_table
FOR EACH ROW
EXECUTE FUNCTION your_table_changes ();
```

As you can see, it will be broadcasting all operations so our users will receive events when records are inserted, updated or deleted from `public.your_table` .

#### Listen on client side

Finally, client side will requires to be set up to listen to the topic `topic:<record id>` to receive the events.

```jsx
const gameId = 'id'
await supabase.realtime.setAuth() // Needed for Realtime Authorization
const changes = supabase
.channel(`topic:${gameId}`)
.on('broadcast', { event: 'INSERT' }, (payload) => console.log(payload))
.on('broadcast', { event: 'UPDATE' }, (payload) => console.log(payload))
.on('broadcast', { event: 'DELETE' }, (payload) => console.log(payload))
.subscribe()
```
11 changes: 11 additions & 0 deletions apps/docs/content/guides/realtime/postgres-changes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,19 @@ In this example we'll set up a database table, secure it with Row Level Security

Go to your project's [Publications settings](https://supabase.com/dashboard/project/_/database/publications), and under `supabase_realtime`, toggle on the tables you want to listen to.

Alternatively, add tables to the `supabase_realtime` publication by running the given SQL:

</StepHikeCompact.Details>

<StepHikeCompact.Code>

```sql
alter publication supabase_realtime
add table your_table_name;
```

</StepHikeCompact.Code>

</StepHikeCompact.Step>

<StepHikeCompact.Step step={4}>
Expand Down
57 changes: 34 additions & 23 deletions apps/docs/features/docs/GuidesMdx.utils.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import matter from 'gray-matter'
import * as Sentry from '@sentry/nextjs'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import { type Metadata, type ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
import { readFile, readdir } from 'node:fs/promises'
import { extname, join, sep } from 'node:path'
import { readdir } from 'node:fs/promises'
import { extname, join, relative, sep } from 'node:path'

import { extractMessageFromAnyError, FileNotFoundError } from '~/app/api/utils'
import { pluckPromise } from '~/features/helpers.fn'
import { cache_fullProcess_withDevCacheBust, existsFile } from '~/features/helpers.fs'
import type { OrPromise } from '~/features/helpers.types'
import { generateOpenGraphImageMeta } from '~/features/seo/openGraph'
import { BASE_PATH } from '~/lib/constants'
import { GUIDES_DIRECTORY, isValidGuideFrontmatter, type GuideFrontmatter } from '~/lib/docs'
import { GuideModelLoader } from '~/resources/guide/guideModelLoader'
import { newEditLink } from './GuidesMdx.template'

const PUBLISHED_SECTIONS = [
Expand Down Expand Up @@ -51,30 +53,39 @@ const getGuidesMarkdownInternal = async (slug: string[]) => {
notFound()
}

let mdx: string
try {
mdx = await readFile(fullPath, 'utf-8')
} catch {
// Not using console.error because this includes pages that are genuine
// 404s and clutters up the logs
console.log('Error reading Markdown at path: %s', fullPath)
notFound()
}
const guide = (await GuideModelLoader.fromFs(relative(GUIDES_DIRECTORY, fullPath))).unwrap()
const content = guide.content ?? ''
const meta = guide.metadata ?? {}

const editLink = newEditLink(
`supabase/supabase/blob/master/apps/docs/content/guides/${relPath}.mdx`
)
if (!isValidGuideFrontmatter(meta)) {
throw Error(`Type of frontmatter is not valid for path: ${fullPath}`)
}

const { data: meta, content } = matter(mdx)
if (!isValidGuideFrontmatter(meta)) {
throw Error('Type of frontmatter is not valid')
}
const editLink = newEditLink(
`supabase/supabase/blob/master/apps/docs/content/guides/${relPath}.mdx`
)

return {
pathname: `/guides/${slug.join('/')}` satisfies `/${string}`,
meta,
content,
editLink,
return {
pathname: `/guides/${slug.join('/')}` satisfies `/${string}`,
meta,
content,
editLink,
}
} catch (error: unknown) {
if (error instanceof FileNotFoundError) {
// Not using console.error because this includes pages that are genuine
// 404s and clutters up the logs
console.log('Could not read Markdown at path: %s', fullPath)
} else {
console.error(
'Error processing Markdown file at path: %s:\n\t%s',
fullPath,
extractMessageFromAnyError(error)
)
Sentry.captureException(error)
}
notFound()
}
}

Expand Down
Loading
Loading