diff --git a/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx b/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx index 0e10827c4a1db..e624cced45fb9 100644 --- a/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx +++ b/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx @@ -3,11 +3,11 @@ title: 'Advanced Log Querying and Filtering' description: 'Query and filter logs with regular expressions' --- -The [Logs Explorer](/dashboard/project/_/logs-explorer) exposes logs from each part of the Supabase stack as a separate table that can be queried and joined using SQL. +The [Logs Explorer](/dashboard/project/_/logs-explorer) exposes logs from each part of the Supabase stack, which you can query and filter using SQL. ![Logs Explorer](/docs/img/guides/platform/logs/logs-explorer.png) -You can access the following logs from the **Sources** drop-down: +You can access the following log sources from the **Sources** drop-down: - `auth_logs`: GoTrue server logs, containing authentication/authorization activity. - `edge_logs`: Edge network logs, containing request and response metadata retrieved from Cloudflare. @@ -17,92 +17,86 @@ You can access the following logs from the **Sources** drop-down: - `realtime_logs`: Realtime server logs, containing client connection information. - `storage_logs`: Storage server logs, containing object upload and retrieval information. -The Logs Explorer uses BigQuery and supports all [available SQL functions and operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators). +The Logs Explorer runs on ClickHouse. Every log line from every source is one row in a single `logs` table, tagged by a `source` column. Structured fields live in a `log_attributes` map, and the raw line is in `event_message`. Filter by `source` to scope a query to one service. -## Timestamp display and behavior + -BigQuery stores each log entry with a `timestamp` as a `TIMESTAMP` data type. Use the appropriate [timestamp function](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp) to use the `timestamp` field in a query. +ClickHouse has been the default engine since June 2026. Projects created before this date use BigQuery, whose `cross join unnest(metadata)` syntax is deprecated. We recommend rewriting those queries in the ClickHouse syntax shown in this guide. -BigQuery renders raw top-level timestamp values as Unix microseconds. To render the timestamps in a human-readable format, use the `DATETIME()` function to convert the Unix timestamp display into an ISO-8601 timestamp. + -```sql --- timestamp column without datetime() -select timestamp from .... --- 1664270180000 +## Timestamp display and behavior --- timestamp column with datetime() -select datetime(timestamp) from .... --- 2022-09-27T09:17:10.439Z -``` +The `timestamp` column is a `DateTime64` value in UTC, formatted as an ISO-8601 string like `2026-06-22T09:34:06.215000`. You can order and compare it directly, so no conversion function is needed. In the Logs Explorer the selected time range is applied for you, so you rarely need to filter on `timestamp` by hand. -## Unnesting arrays +```sql +select timestamp, event_message +from logs +where source = 'edge_logs' +order by timestamp desc +limit 100; +``` -Each log event stores metadata as an array of objects with multiple levels, as you can see by selecting single log events in the Logs Explorer. To query arrays, use `unnest()` on each array field and add it to the query as a join. This allows you to reference the nested objects with an alias and select their individual fields. +## Reading fields from log_attributes -For example, to query the edge logs without any joins: +Structured fields live in the `log_attributes` map. Read a field with bracket access, keeping the full dotted key. There are no unnesting joins. ```sql -select timestamp, metadata from edge_logs as t; +select + log_attributes['request.method'] as method, + log_attributes['request.path'] as path, + log_attributes['response.status_code'] as status +from logs +where source = 'edge_logs' +limit 100; ``` -The Logs Explorer renders the resulting `metadata` key as an array of objects in the Logs Explorer. In the following diagram, each box represents a nested array of objects: - -![Without Unnesting](/docs/img/unnesting-none.png) +The key keeps the full dotted path, with the `metadata` root dropped. What BigQuery expressed as `metadata.request.cf.country` is `log_attributes['request.cf.country']`. Keep the full prefix rather than shortening it. -Perform a `cross join unnest()` to work with the keys nested in the `metadata` key. - -To query for a nested value, add a join for each array level: +Map values are always strings. To compare or aggregate a numeric field, wrap it in `toInt32OrZero`, which returns `0` for a missing or non-numeric value: ```sql -select timestamp, request.method, header.cf_ipcountry -from - edge_logs as t - cross join unnest(t.metadata) as metadata - cross join unnest(metadata.request) as request - cross join unnest(request.headers) as header; +select count() as server_errors +from logs +where source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) between 500 and 599; ``` -This surfaces the following columns available for selection: -![With Two Level Unnesting](/docs/img/unnesting-2.png) - -This allows you to select the `method` and `cf_ipcountry` columns. In JS dot notation, the full paths for each selected column are: +Do not guess keys. Discover the keys a source sets from recent rows: -- `metadata[].request[].method` -- `metadata[].request[].headers[].cf_ipcountry` +```sql +select arrayJoin(mapKeys(log_attributes)) as key, count() as n +from logs +where source = 'postgres_logs' +group by key +order by n desc +limit 100; +``` ## LIMIT and result row limitations -The Logs Explorer has a maximum of 1000 rows per run. Use `LIMIT` to optimize your queries by reducing the number of rows returned further. +The Logs Explorer has a maximum of 1000 rows per run. Use `LIMIT` to reduce the number of rows returned further. ## Best practices -1. Include a filter over **timestamp** +1. **Use a narrow time range.** -Querying your entire log history might seem appealing. For **Enterprise** customers that have a large retention range, you run the risk of timeouts due to additional time required to scan the larger dataset. +The Logs Explorer applies the time range you select, so keep it tight. Querying a very large range risks timeouts, especially for Enterprise customers with long retention, because of the extra data scanned. -2. Avoid selecting large nested objects. Select individual values instead. +2. **Select only the fields you need.** -When querying large objects, the columnar storage engine selects each column associated with each nested key, resulting in a large number of columns being selected. This inadvertently impacts the query speed and may result in timeouts or memory errors, especially for projects with a lot of logs. - -Instead, select only the values required. +Selecting the whole `log_attributes` map, or every column, reads far more data than you need and slows the query down. Select the specific keys instead. ```sql --- ❌ Avoid doing this -select - datetime(timestamp), - m as metadata -- <- metadata contains many nested keys -from - edge_logs as t - cross join unnest(t.metadata) as m; - --- ✅ Do this -select - datetime(timestamp), - r.method -- <- select only the required values -from - edge_logs as t - cross join unnest(t.metadata) as m - cross join unnest(m.request) as r; +-- ❌ Avoid this: selecting the whole attributes map +select timestamp, log_attributes +from logs +where source = 'edge_logs'; + +-- ✅ Do this: select only the keys you need +select timestamp, log_attributes['request.method'] as method +from logs +where source = 'edge_logs'; ``` ## Examples and templates @@ -112,62 +106,59 @@ The Logs Explorer includes **Templates** (available in the Templates tab or the For example, you can enter the following query in the SQL Editor to retrieve each user's IP address: ```sql -select datetime(timestamp), h.x_real_ip -from - edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as r - cross join unnest(r.headers) as h -where h.x_real_ip is not null and r.method = "GET"; +select timestamp, log_attributes['request.headers.x_real_ip'] as x_real_ip +from logs +where source = 'edge_logs' + and log_attributes['request.headers.x_real_ip'] != '' + and log_attributes['request.method'] = 'GET' +order by timestamp desc +limit 100; ``` ## Understanding field references -You query log tables with a subset of BigQuery SQL syntax. They all have three columns: `event_message`, `timestamp`, and `metadata`. - -| column | description | -| --------------- | --------------------------- | -| `timestamp` | time event was recorded | -| `event_message` | the log's message | -| `metadata` | information about the event | +Every log source shares the same `logs` table. Each row has these columns: -The `metadata` column is an array of JSON objects that stores important details about each recorded event. For example, in the Postgres table, the `metadata.parsed.error_severity` field indicates the error level of an event. To work with its values, you need to `unnest` them using a `cross join`. +| column | description | +| ---------------- | -------------------------------------------------- | +| `id` | unique log identifier | +| `timestamp` | time the event was recorded | +| `event_message` | the log's message | +| `severity_text` | log level, when the source sets one | +| `source` | the service the log came from | +| `log_attributes` | structured per-source fields, keyed by dotted path | -This approach is commonly used with JSON and array columns, so it might look a bit unfamiliar if you're not used to working with these data types. +Service-specific details live in `log_attributes`. For example, in `postgres_logs` the `log_attributes['parsed.error_severity']` field holds the error level of an event. Read those fields with bracket access: ```sql select event_message, - parsed.error_severity, - parsed.user_name -from - postgres_logs - -- extract first layer - cross join unnest(postgres_logs.metadata) as metadata - -- extract second layer - cross join unnest(metadata.parsed) as parsed; + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name +from logs +where source = 'postgres_logs' +limit 100; ``` ## Expanding results -Logs returned by queries may be difficult to read in table format. A row can be double-clicked to expand the results into more readable JSON: +Logs returned by queries may be difficult to read in table format. Double-click a row to expand the result into more readable JSON: ![Expanding log results](/docs/img/guides/platform/expanded-log-results.png) ## Filtering with [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) -The Logs use BigQuery-style regular expressions with the [regexp_contains function](https://cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#regexp_contains). In its most basic form, it checks if a string is present in a specified column. +Use the ClickHouse [`match` function](https://clickhouse.com/docs/sql-reference/functions/string-search-functions#match) for regular expressions. In its most basic form, it checks whether a pattern is present in a column. ```sql -select - cast(timestamp as datetime) as timestamp, - event_message, - metadata -from postgres_logs -where regexp_contains(event_message, 'is present'); +select timestamp, event_message +from logs +where source = 'postgres_logs' + and match(event_message, 'is present') +limit 100; ``` -There are multiple operators that you should consider using: +There are multiple operators to consider using. ### Find messages that start with a phrase @@ -175,46 +166,53 @@ There are multiple operators that you should consider using: ```sql -- find only messages that start with connection -regexp_contains(event_message, '^connection') +match(event_message, '^connection') ``` -### Find messages that end with a phrase: +### Find messages that end with a phrase `$` only looks for values at the end of the string ```sql --- find only messages that ends with port=12345 -regexp_contains(event_message, 'port=12345$') +-- find only messages that end with port=12345 +match(event_message, 'port=12345$') ``` -### Ignore case sensitivity: +### Ignore case sensitivity `(?i)` ignores capitalization for all proceeding characters ```sql -- find all event_messages with the word "connection" -regexp_contains(event_message, '(?i)COnnecTion') +match(event_message, '(?i)COnnecTion') ``` -### Wildcards: +For a plain case-insensitive substring match, `ilike` is simpler: -`.` can represent any string of characters +```sql +-- find all event_messages containing "connection", in any case +event_message ilike '%connection%' +``` + +### Wildcards + +`.` matches any single character, and `.*` matches any sequence of characters ```sql -- find event_messages like "helloworld" -regexp_contains(event_message, 'hello.world') +match(event_message, 'hello.*world') ``` -### Alphanumeric ranges: +### Alphanumeric ranges -`[1-9a-zA-Z]` finds any strings with only numbers and letters +`[0-9a-zA-Z]` matches a single alphanumeric character. Anchor it with `^[0-9a-zA-Z]+$` to match a value that is entirely alphanumeric. ```sql --- find event_messages that contain a number between 1 and 5 (inclusive) -regexp_contains(event_message, '[1-5]') +-- find event_messages that contain a digit between 1 and 5 (inclusive) +match(event_message, '[1-5]') ``` -### Repeated values: +### Repeated values `x*` zero or more x `x+` one or more x @@ -223,87 +221,70 @@ regexp_contains(event_message, '[1-5]') `x{3}` exactly 3 x ```sql --- find event_messages that contains any sequence of 3 digits -regexp_contains(event_message, '[0-9]{3}') +-- find event_messages that contain any sequence of 3 digits +match(event_message, '[0-9]{3}') ``` -### Escaping reserved characters: +### Escaping reserved characters -`\.` interpreted as period `.` instead of as a wildcard +`\.` is interpreted as a period `.` instead of as a wildcard ```sql -- escapes . -regexp_contains(event_message, 'hello world\.') +match(event_message, 'hello world\.') ``` -### `or` statements: +### `or` statements `x|y` any string with `x` or `y` present ```sql --- find event_messages that have the word 'started' followed by either the word "host" or "authenticated" -regexp_contains(event_message, 'started host|authenticated') +-- find event_messages that have the word 'started' followed by either "host" or "authenticated" +match(event_message, 'started (host|authenticated)') ``` -### `and`/`or`/`not` statements in SQL: +### `and`/`or`/`not` statements in SQL -`and`, `or`, and `not` are all native terms in SQL and can be used in conjunction with regular expressions to filter results +`and`, `or`, and `not` are native terms in SQL and can be used with regular expressions to filter results ```sql -select - cast(timestamp as datetime) as timestamp, - event_message, - metadata -from postgres_logs -where - (regexp_contains(event_message, 'connection') and regexp_contains(event_message, 'host')) - or not regexp_contains(event_message, 'received'); +select timestamp, event_message +from logs +where source = 'postgres_logs' + and ( + (match(event_message, 'connection') and match(event_message, 'host')) + or not match(event_message, 'received') + ) +limit 100; ``` -### Filtering and unnesting example +### Filtering example -Filter for Postgres: +Filter for Postgres errors: ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, - parsed.error_severity, - parsed.user_name, + timestamp, + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, event_message -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed -where regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC') +from logs +where source = 'postgres_logs' + and match(log_attributes['parsed.error_severity'], 'ERROR|FATAL|PANIC') order by timestamp desc limit 100; ``` ## Limitations -### Log tables cannot be joined together - -Each product table operates independently without the ability to join with other log tables. This may change in the future. - -### The `with` keyword and subqueries are not supported - -The parser does not yet support `with` and subquery statements. +### The wildcard operator `*` is not supported -### The `ilike` and `similar to` keywords are not supported - -Although you can use `like` and other comparison operators, `ilike` and `similar to` are incompatible with BigQuery's variant of SQL. Use `regexp_contains` as an alternative. - -### The wildcard operator `*` to select columns is not supported - -The log parser is not able to parse the `*` operator for column selection. Instead, you can access all fields from the `metadata` column: +The logs query surface rejects `select *` and `count(*)`. List the columns you need, and use `count()` for row counts: ```sql -select - cast(postgres_logs.timestamp as datetime) as timestamp, - event_message, - metadata -from - +select timestamp, event_message, log_attributes['parsed.error_severity'] as error_severity +from logs +where source = 'postgres_logs' order by timestamp desc limit 100; ``` diff --git a/apps/docs/content/guides/telemetry/logs.mdx b/apps/docs/content/guides/telemetry/logs.mdx index cc770ee33c49e..16788ab950be7 100644 --- a/apps/docs/content/guides/telemetry/logs.mdx +++ b/apps/docs/content/guides/telemetry/logs.mdx @@ -261,66 +261,52 @@ You can access the following logs from the **Sources** drop-down: ## Querying with the Logs Explorer -The Logs Explorer uses BigQuery and supports all [available SQL functions and operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators). +The Logs Explorer runs on **ClickHouse**. Every log line from every source is a single row in the `logs` table, tagged by a `source` column. Structured fields live in a `log_attributes` map whose values are strings, and the raw line is in `event_message`. - + -The Logs Explorer now defaults to ClickHouse, where every source shares a single `logs` table and nested fields are read from a `log_attributes` map rather than with `cross join unnest(metadata)`. The examples below still use the legacy BigQuery syntax and are being migrated. Adjust field access accordingly on projects already on ClickHouse. +ClickHouse has been the default engine since June 2026. Projects created before this date use BigQuery, whose `cross join unnest(metadata)` syntax is deprecated. We recommend rewriting those queries in the ClickHouse syntax shown in this guide. -### Timestamp display and behavior - -Each log entry is stored with a `timestamp` as a `TIMESTAMP` data type. Use the appropriate [timestamp function](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp) to use the `timestamp` field in a query. +Read fields with bracket access, keeping the full dotted key, for example `log_attributes['request.path']` rather than `path`. Wrap numeric values in `toInt32OrZero(...)`, which returns `0` for a missing or non-numeric value. Use `count()` rather than `count(*)`. -Raw top-level timestamp values are rendered as unix microsecond. To render the timestamps in a human-readable format, use the `DATETIME()` function to convert the unix timestamp display into an ISO-8601 timestamp. +For example, to find failing API requests: ```sql --- timestamp column without datetime() -select timestamp from .... --- 1664270180000 - --- timestamp column with datetime() -select datetime(timestamp) from .... --- 2022-09-27T09:17:10.439Z +select timestamp, + toInt32OrZero(log_attributes['response.status_code']) as status, + log_attributes['request.path'] as path +from logs +where source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) >= 400 +order by timestamp desc +limit 100; ``` -### Unnesting arrays - -Each log event stores metadata an array of objects with multiple levels, and can be seen by selecting single log events in the Logs Explorer. To query arrays, use `unnest()` on each array field and add it to the query as a join. This allows you to reference the nested objects with an alias and select their individual fields. - -For example, to query the edge logs without any joins: +For example, to find a specific Postgres SQLSTATE (`42501` permission denied, `42P01` relation missing, `23505` duplicate key): ```sql -select timestamp, metadata from edge_logs as t; +select timestamp, log_attributes['parsed.user_name'] as role, event_message +from logs +where source = 'postgres_logs' + and log_attributes['parsed.sql_state_code'] = '42501' +order by timestamp desc +limit 100; ``` -The resulting `metadata` key is rendered as an array of objects in the Logs Explorer. In the following diagram, each box represents a nested array of objects: - -{/* */} - -![Without Unnesting](/docs/img/unnesting-none.png) - -Perform a `cross join unnest()` to work with the keys nested in the `metadata` key. - -To query for a nested value, add a join for each array level: +Do not guess `log_attributes` keys. A missing key returns an empty string rather than an error, so a wrong key makes a working query look empty instead of failing. Discover the real keys for a source, or read `event_message`, which always holds the full line. Statement text and error detail live there, not in `parsed.query` or `parsed.detail`, which are usually empty: ```sql -select timestamp, request.method, header.cf_ipcountry -from - edge_logs as t - cross join unnest(t.metadata) as metadata - cross join unnest(metadata.request) as request - cross join unnest(request.headers) as header; +select arrayJoin(mapKeys(log_attributes)) as key, count() as n +from logs +where source = 'postgres_logs' +group by key +order by n desc +limit 100; ``` -This surfaces the following columns available for selection: -![With Two Level Unnesting](/docs/img/unnesting-2.png) - -This allows you to select the `method` and `cf_ipcountry` columns. In JS dot notation, the full paths for each selected column are: - -- `metadata[].request[].method` -- `metadata[].request[].headers[].cf_ipcountry` +If you use the [Supabase MCP server](/docs/guides/getting-started/mcp), the `query_logs` tool runs a custom ClickHouse query like the ones above on hosted projects. The `get_logs` tool returns a service's recent logs without SQL; it is deprecated on hosted projects in favor of `query_logs`, and remains the option for local and self-hosted projects. ### LIMIT and result row limitations @@ -339,22 +325,15 @@ When querying large objects, the columnar storage engine selects each column ass Instead, select only the values required. ```sql --- ❌ Avoid doing this -select - datetime(timestamp), - m as metadata -- <- metadata contains many nested keys -from - edge_logs as t - cross join unnest(t.metadata) as m; - --- ✅ Do this -select - datetime(timestamp), - r.method -- <- select only the required values -from - edge_logs as t - cross join unnest(t.metadata) as m - cross join unnest(m.request) as r; +-- ❌ Avoid this: selecting the whole attributes map +select timestamp, log_attributes +from logs +where source = 'edge_logs'; + +-- ✅ Do this: select only the keys you need +select timestamp, log_attributes['request.method'] as method +from logs +where source = 'edge_logs'; ``` 3. **Query one source at a time.** @@ -367,51 +346,60 @@ Identify which service owns the problem from the error or status code first, the A misspelled or non-existent field name either errors or silently returns nothing, which leaves a working query look empty. Confirm field names in the [field reference](#logs-field-reference), or select `event_message` and inspect a sample row first. -### Examples and templates - -The Logs Explorer includes **Templates** (available in the Templates tab or the dropdown in the Query tab) to help you get started. - -For example, you can enter the following query in the SQL Editor to retrieve each user's IP address: - -```sql -select datetime(timestamp), h.x_real_ip -from - edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as r - cross join unnest(r.headers) as h -where h.x_real_ip is not null and r.method = "GET"; -``` - ### Logs field reference -Refer to the full field reference for each available source below. Do note that in order to access each nested key, you would need to perform the [necessary unnesting joins](#unnesting-arrays) +Refer to the full field reference for each source below. Each source's structured fields are listed as ClickHouse `log_attributes` keys, alongside the base columns (`id`, `timestamp`, `event_message`, `severity_text`, `source`) that every source has. - {(logConstants) => ( - - {logConstants.schemas.map((schema) => ( - - - - - - - - - - {schema.fields - .sort((a, b) => a.path - b.path) - .map((field) => ( + {(logConstants) => { + const otelBaseFields = [ + { path: 'id', type: 'string' }, + { path: 'timestamp', type: 'datetime' }, + { path: 'event_message', type: 'string' }, + { path: 'severity_text', type: 'string' }, + { path: 'source', type: 'string' }, + ] + const baseColumns = new Set(otelBaseFields.map((field) => field.path)) + return ( + + {logConstants.schemas.map((schema) => { + const fields = [ + ...otelBaseFields, + ...schema.fields + .filter((field) => !baseColumns.has(field.path)) + .map((field) => ({ + path: `log_attributes['${field.path.replace(/^metadata\./, '')}']`, + type: field.type, + })), + ] + return ( + +
PathType
+ - - + + - ))} - -
{field.path}{field.type}PathType
-
- ))} -
- )} + + + {fields.map((field) => ( + + {field.path} + {field.type} + + ))} + + + + ) + })} + + ) + }}
diff --git a/apps/docs/features/docs/Reference.api.utils.ts b/apps/docs/features/docs/Reference.api.utils.ts index 480da7d88d4c5..2d87c9205085d 100644 --- a/apps/docs/features/docs/Reference.api.utils.ts +++ b/apps/docs/features/docs/Reference.api.utils.ts @@ -26,6 +26,7 @@ export interface IApiEndPoint { security?: Array 'x-oauth-scope'?: string 'x-allowed-plans'?: string[] + 'x-fga-permissions'?: string[][] } export type ISchema = diff --git a/apps/docs/features/docs/Reference.sections.tsx b/apps/docs/features/docs/Reference.sections.tsx index 98654285c451e..cbced0f844c01 100644 --- a/apps/docs/features/docs/Reference.sections.tsx +++ b/apps/docs/features/docs/Reference.sections.tsx @@ -275,10 +275,7 @@ async function ApiEndpointSection({ link, section, servicePath }: ApiEndpointSec : await getApiEndpointById(section.id) if (!endpointDetails) return null - const endpointFgaPermissionGroups = - endpointDetails.security - ?.filter((sec) => 'fga_permissions' in sec) - .map((sec) => sec.fga_permissions) ?? [] + const endpointFgaPermissionGroups = endpointDetails['x-fga-permissions'] ?? [] const pathParameters = (endpointDetails.parameters ?? []).filter((param) => param.in === 'path') const queryParameters = (endpointDetails.parameters ?? []).filter((param) => param.in === 'query') const bodyParameters = diff --git a/apps/studio/components/grid/components/editor/JsonEditor.tsx b/apps/studio/components/grid/components/editor/JsonEditor.tsx index da56dfba3c3ae..d560fc822246e 100644 --- a/apps/studio/components/grid/components/editor/JsonEditor.tsx +++ b/apps/studio/components/grid/components/editor/JsonEditor.tsx @@ -23,6 +23,8 @@ import { isTableLike } from '@/data/table-editor/table-editor-types' import { useGetCellValueMutation } from '@/data/table-rows/get-cell-value-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { prettifyJSON, removeJSONTrailingComma, tryParseJson } from '@/lib/helpers' +import { RoleImpersonationState } from '@/lib/role-impersonation' +import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' const verifyJSON = (value: string) => { try { @@ -86,6 +88,7 @@ export const JsonEditor = ({ const applyChangesLabel = isQueueOperationsEnabled ? 'Queue changes' : 'Save changes' const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation() + const roleImpersonationState = useRoleImpersonationStateSnapshot() const loadFullValue = () => { if (selectedTable === undefined || project === undefined || !isTableLike(selectedTable)) return @@ -104,6 +107,7 @@ export const JsonEditor = ({ pkMatch, projectRef: project?.ref, connectionString: project?.connectionString, + roleImpersonationState: roleImpersonationState as RoleImpersonationState, }, { onSuccess: (data) => { diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index f049c1df4a0c5..c96c96dcf647b 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -26,6 +26,8 @@ import { useTableEditorQuery } from '@/data/table-editor/table-editor-query' import { isTableLike } from '@/data/table-editor/table-editor-types' import { useGetCellValueMutation } from '@/data/table-rows/get-cell-value-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { RoleImpersonationState } from '@/lib/role-impersonation' +import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' export const TextEditor = ({ row, @@ -58,6 +60,7 @@ export const TextEditor = ({ const applyChangesLabel = isQueueEnabled ? 'Queue changes' : 'Save changes' const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation() + const roleImpersonationState = useRoleImpersonationStateSnapshot() const isTruncated = isValueTruncated(initialValue) @@ -78,6 +81,7 @@ export const TextEditor = ({ pkMatch, projectRef: project?.ref, connectionString: project?.connectionString, + roleImpersonationState: roleImpersonationState as RoleImpersonationState, }, { onSuccess: (data) => setValue(data) } ) diff --git a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts index cd011a216db76..9315eef4ba0c6 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts @@ -169,7 +169,7 @@ export const isOAuthInstalled = ({ ) } - if (integration.id === 'grafana') { + if (integration.id === 'grafana' || integration.id === 'grafana-cloud') { // Grafana is not yet sending integration status, so just use presence of API key. return ( isOAuthAppAuthorized(projectData, integration) || diff --git a/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx b/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx index 5ab944ae5b1b2..681f22bd8091a 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx @@ -48,6 +48,21 @@ const STABLE_INTEGRATIONS = [ navigate: () => null, navigation: [{ route: 'overview', label: 'Overview' }], }, + { + id: 'grafana-cloud', + name: 'Grafana', + type: 'oauth', + source: 'Partner', + description: 'Grafana', + content: 'Grafana overview content', + docsUrl: null, + siteUrl: null, + author: { name: 'Grafana Labs' }, + oauthAppId: 'grafana-app', + icon: () => null, + navigate: () => null, + navigation: [{ route: 'overview', label: 'Overview' }], + }, ] const STABLE_EMPTY_ARR = [] as unknown[] vi.mock('@/components/interfaces/Integrations/Landing/useAvailableIntegrations', () => ({ diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx index 5c8c8b097bd9b..2d64bb82d65ca 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx @@ -14,6 +14,7 @@ export type { MarketplaceSource } from '@/components/interfaces/Integrations/Lan // Defines featured integrations and their order in the featured hero export const FEATURED_INTEGRATION_IDS = [ 'grafana', + 'grafana-cloud', 'cron', 'queues', 'stripe_sync_engine', diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx index 8a0fe9880feb5..1714b1f6e5268 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx @@ -25,6 +25,10 @@ const FEATURED_INTEGRATION_IMAGES: Record { - const { state } = activity - if (state === 'active' && activity.query_start) { - return dayjs().utc().diff(dayjs(activity.query_start).utc(), 'second') - } - if (state === 'idle' && activity.state_change) { - return dayjs().utc().diff(dayjs(activity.state_change).utc(), 'second') - } - if ( - (state === 'idle in transaction' || state === 'idle in transaction (aborted)') && - activity.transaction_start - ) { - return dayjs().utc().diff(dayjs(activity.transaction_start).utc(), 'second') - } - return null -} - -const getBadgeVariant = (activity: DatabaseActivity) => { - const { state } = activity - if (state === 'active') return 'success' - if (state === 'idle in transaction' || state === 'idle in transaction (aborted)') return 'warning' - return 'default' -} const DEFAULT_ROLES_FILTER = ['anon', 'authenticated', 'postgres'] @@ -312,226 +247,3 @@ export const Activity = ({ live }: ActivityProps) => { ) } - -const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { - const { data: project } = useSelectedProjectQuery() - const [showTerminateConfirmDialog, setShowTerminateConfirmDialog] = useState(false) - const [selectedPid, setSelectedPid] = useQueryState('pid', parseAsInteger) - - const { data } = useDatabaseActivityQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const { data: roles } = useDatabaseRolesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const superuserRoles = roles?.filter((role) => role.isSuperuser).map((role) => role.name) - - const { mutateAsync: abortQuery } = useQueryAbortMutation({ - onSuccess: () => { - toast.success(`Successfully aborted query (ID: ${activity.pid})`) - }, - }) - - const durationSeconds = getDuration(activity) - const badgeVariant = getBadgeVariant(activity) - - /** - * Queries in "active state": 30s threshold is long enough (most CRUD queries should be quick) - * Queries in "idle in transaction" state: This actively holds locks and blocks autovacuum while contributing nothing, so important to surface early at 10s threshold - */ - const queryRunningLongWarning = - !!durationSeconds && - ((activity.state === 'active' && durationSeconds >= WARN_DURATION_ACTIVE_QUERY) || - ((activity.state === 'idle in transaction' || - activity.state === 'idle in transaction (aborted)') && - durationSeconds >= WARN_DURATION_IDLE_TXN)) - - const onConfirmTerminate = async () => { - try { - await abortQuery({ - pid: activity.pid, - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - } catch (error) {} - } - - return ( - <> - - - {selectedPid === activity.pid && ( -
- )} - - - {activity.state} - - {activity.state && ( - {QUERY_STATE_TOOLTIP[activity.state]} - )} - -
- - - -

- {!!activity.query ? activity.query : 'No query'} -

-
- {activity.query && ( - - code]:text-xs max-h-64', - '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11' - )} - wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-0')} - language="pgsql" - value={formatSql(activity.query)} - /> - - )} -
-
- - { - toast.success('Copied PID') - copyToClipboard(activity.pid.toString()) - }} - > - PID: {activity.pid} - - Click to copy - - · - {activity.role_name} - {activity.application_name && ( - <> - · - {activity.application_name} - - )} -
-
- - -

- {durationSeconds !== null ? ( - formatDuration(durationSeconds * 1000, 0) - ) : ( - - )} -

-
- - - {activity.blocked_by.length > 0 ? ( - activity.blocked_by.map((pid, index) => { - const blockedProcess = data?.find((x) => x.pid === pid) - - return ( - - {index > 0 && ', '} - - setSelectedPid(pid)} - > - {pid} - - -

- {blockedProcess?.query} -

-

- PID: {blockedProcess?.pid} - · - {blockedProcess?.role_name} - {blockedProcess?.application_name && ( - <> - · - {blockedProcess?.application_name} - - )} -

-
-
-
- ) - }) - ) : ( - - )} -
- - - - - + ) + })} + + +
+
+

+ Products +

+
{CHANGELOG_PRODUCT_TAGS.map(({ slug, label }) => { const on = selectedTags.has(slug) return (
+
+
+

+ Product stage +

+
+ {CHANGELOG_PRODUCT_STAGES.map(({ slug, label }) => { + const on = selectedStages.has(slug) + return ( + + ) + })} +
+
+
+
+

+ Self-hosted +

+
+ {CHANGELOG_SELF_HOSTED_OPTIONS.map(({ slug, label }) => { + const on = selectedSelfHosted.has(slug) + return ( + + ) + })} +
+
)} @@ -358,8 +493,8 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) {
{featured.map((entry) => (
@@ -375,24 +510,12 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) { )} -

- {dayjs(entry.created_at).format('MMM D, YYYY')} -

- {entry.labels && entry.labels.length > 0 && ( -
- {entry.labels.map((label) => ( - - - {changelogLabelDisplayName(label.name)} - - - ))} -
- )} +
+

+ {dayjs(entry.created_at).format('MMM D, YYYY')} +

+
+
@@ -404,6 +527,21 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) { )} + {entry.affectedProducts.length > 0 && ( +
+ {entry.affectedProducts.map((product) => ( + + + {product} + + + ))} +
+ )}
))} @@ -416,7 +554,7 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) { )}
-
+
( +const ChangelogDetailPage = ({ title, created_at, slug, frontmatter, source }: PageProps) => ( <> @@ -54,16 +46,18 @@ const ChangelogDetailPage = ({ title, url, created_at, slug, source, labels }: P

{title}

-

- {dayjs(created_at).format('MMM D, YYYY')} -

+
+

+ {dayjs(created_at).format('MMM D, YYYY')} +

+
{'error' in source ? ( -

Error rendering blog post: {source.error.message}

+

Error rendering changelog entry: {source.error.message}

) : ( )} @@ -72,7 +66,7 @@ const ChangelogDetailPage = ({ title, url, created_at, slug, source, labels }: P
@@ -88,53 +82,23 @@ export const getStaticPaths: GetStaticPaths = async () => { export const getStaticProps: GetStaticProps = async ({ params }) => { const raw = params?.slug - const slugStr = Array.isArray(raw) ? raw[0] : (raw ?? '') - // The slug always starts with the numeric discussion number. - const number = parseInt(slugStr, 10) - if (!Number.isFinite(number) || number <= 0) return { notFound: true } - - try { - const octokit = createChangelogOctokit() - const discussion = await fetchChangelogDiscussionByNumber( - octokit, - 'supabase', - 'supabase', - number - ) - - if (!discussion || discussion.category?.id !== CHANGELOG_CATEGORY_ID) { - return { notFound: true } - } - - const expectedSlug = changelogEntrySlug(number, discussion.title) + const slug = Array.isArray(raw) ? raw[0] : (raw ?? '') - // Redirect number-only or mismatched slugs to the canonical slug URL. - if (slugStr !== expectedSlug) { - return { redirect: { destination: `/changelog/${expectedSlug}`, permanent: true } } - } + const entries = await getChangelogEntries() + const entry = entries.find((e) => e.slug === slug) + if (!entry) return { notFound: true } - const source = await mdxSerialize(discussion.body) - const created_at = - discussionDisplayDate({ - title: discussion.title, - createdAt: discussion.createdAt, - }) ?? discussion.createdAt + const source = await mdxSerialize(entry.bodySection) - return { - props: { - title: discussion.title, - url: discussion.url, - created_at, - number, - slug: expectedSlug, - source, - labels: discussion.labels?.nodes ?? [], - }, - revalidate: 900, - } - } catch (e) { - console.error(e) - return { notFound: true } + return { + props: { + title: entry.frontmatter.title, + created_at: entry.sortDate, + slug: entry.slug, + frontmatter: entry.frontmatter, + source, + }, + revalidate: 900, } } diff --git a/apps/www/scripts/data/changelog-deleted-discussions.json b/apps/www/scripts/data/changelog-deleted-discussions.json deleted file mode 100644 index d4d97616816a1..0000000000000 --- a/apps/www/scripts/data/changelog-deleted-discussions.json +++ /dev/null @@ -1,21 +0,0 @@ -[ - { "title": "Platform updates: October 2023", "createdAt": "2023-11-06T16:25:12Z" }, - { "title": "Platform update September 2023", "createdAt": "2023-10-06T09:23:56Z" }, - { "title": "Platform updates: August 2023", "createdAt": "2023-09-08T13:00:39Z" }, - { "title": "Platform updates June 2023", "createdAt": "2023-07-07T16:09:32Z" }, - { "title": "Platform updates: May 2023", "createdAt": "2023-06-09T16:40:16Z" }, - { "title": "Platform updates: April 2023", "createdAt": "2023-05-10T18:40:02Z" }, - { "title": "Platform Update February 2023", "createdAt": "2023-03-09T12:06:01Z" }, - { "title": "Platform update January 2023", "createdAt": "2023-02-08T18:29:41Z" }, - { "title": "Platform Updates November 2022", "createdAt": "2022-12-08T12:00:48Z" }, - { "title": "Platform updates: October 2022", "createdAt": "2022-11-02T16:07:47Z" }, - { "title": "Platform Update September 2022", "createdAt": "2022-10-07T10:18:21Z" }, - { "title": "Platform updates: 30 Nov 2021", "createdAt": "2021-11-30T10:06:55Z" }, - { "title": "October Beta 2021", "createdAt": "2021-11-08T13:14:35Z" }, - { "title": "September Beta 2021", "createdAt": "2021-10-04T18:10:57Z" }, - { "title": "August Beta 2021", "createdAt": "2021-09-13T09:47:18Z" }, - { "title": "July Beta 2021", "createdAt": "2021-08-12T13:46:14Z" }, - { "title": "June Beta 2021", "createdAt": "2021-07-04T13:23:35Z" }, - { "title": "May Beta 2021", "createdAt": "2021-06-10T11:53:14Z" }, - { "title": "April Beta 2021", "createdAt": "2021-05-05T05:17:27Z" } -] diff --git a/apps/www/scripts/generateStaticContent.mjs b/apps/www/scripts/generateStaticContent.mjs index 0fac6a1eaed11..04f694b38c0a6 100644 --- a/apps/www/scripts/generateStaticContent.mjs +++ b/apps/www/scripts/generateStaticContent.mjs @@ -378,198 +378,140 @@ try { console.warn('Error generating RSS feed:', error) } -// Changelog RSS → public/changelog-rss.xml (same GitHub App + category as lib/changelog-github.ts) -try { - const appId = process.env.GITHUB_CHANGELOG_APP_ID - const installationId = process.env.GITHUB_CHANGELOG_APP_INSTALLATION_ID - const privateKey = process.env.GITHUB_CHANGELOG_APP_PRIVATE_KEY +// Changelog RSS + changelog.md → sourced from supabase/changelog entries/*.md (private repo). +// Missing secret: warns and skips outside Vercel, but fails Vercel builds so they can't +// publish stale generated changelog files. Generic CI (typecheck/lint on GitHub Actions) +// has no access to this secret and isn't publishing anything, so it only warns too. +async function generateChangelogContent() { + const appId = process.env.CHANGELOG_SYNC_APP_ID + const installationId = process.env.CHANGELOG_SYNC_APP_INSTALLATION_ID + const privateKey = process.env.CHANGELOG_SYNC_APP_PRIVATE_KEY if (!appId || !installationId || !privateKey) { - console.warn('Skipping changelog RSS: missing GITHUB_CHANGELOG_APP_* env vars') - } else { - const { createAppAuth } = await import('@octokit/auth-app') - const { Octokit } = await import('@octokit/core') - const { paginateGraphql } = await import('@octokit/plugin-paginate-graphql') - - const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug, changelogEntrySlug } = - await import('../lib/changelog-rss.mjs') - const rewritesPath = path.join(__dirname, 'data/changelog-deleted-discussions.json') - const rewrites = JSON.parse(await fs.readFile(rewritesPath, 'utf8')) - const discussionDisplayDate = (item) => { - const dateRewrite = rewrites.find( - (r) => item.title && r.title && item.title.includes(r.title) - ) - return dateRewrite ? dateRewrite.createdAt : item.createdAt + if (process.env.VERCEL) { + throw new Error('CHANGELOG_SYNC_APP_* env vars not set — cannot generate changelog content') } + console.warn('⚠️ CHANGELOG_SYNC_APP_* env vars not set — skipping changelog RSS/md generation') + return + } - const CHANGELOG_CATEGORY_ID = 'DIC_kwDODMpXOc4CAFUr' - - const changelogQuery = ` - query changelogDiscussionMetadata($cursor: String, $owner: String!, $repo: String!, $categoryId: ID!) { - repository(owner: $owner, name: $repo) { - discussions( - first: 100 - after: $cursor - categoryId: $categoryId - orderBy: { field: CREATED_AT, direction: DESC } - ) { - nodes { - number - title - body - createdAt - url - labels(first: 25) { - nodes { - name - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - ` - - const ExtendedOctokit = Octokit.plugin(paginateGraphql) - const octokit = new ExtendedOctokit({ - authStrategy: createAppAuth, - auth: { - appId, - installationId, - privateKey: privateKey.replace(/\\n/g, '\n'), - }, - }) + const { getPublishedChangelogEntries, fetchChangelogEntryFilesFromTarball, CHANGE_TYPE_LABELS } = + await import('../lib/changelog-entries-core.mjs') + const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } = await import( + '../lib/changelog-rss.mjs' + ) + const { createAppAuth } = await import('@octokit/auth-app') + const { Octokit } = await import('@octokit/core') + const octokit = new Octokit({ + authStrategy: createAppAuth, + auth: { appId, installationId, privateKey: privateKey.replace(/\\n/g, '\n') }, + }) - const collected = [] - let cursor = null - let hasNextPage = true - while (hasNextPage) { - const { - repository: { - discussions: { nodes, pageInfo }, - }, - } = await octokit.graphql(changelogQuery, { - owner: 'supabase', - repo: 'supabase', - categoryId: CHANGELOG_CATEGORY_ID, - cursor, - }) - collected.push(...nodes) - hasNextPage = pageInfo.hasNextPage - cursor = pageInfo.endCursor + // Single tarball request — fetching each entry file individually trips + // GitHub's secondary rate limit once the entries directory gets large. + const files = await fetchChangelogEntryFilesFromTarball(octokit, { + owner: 'supabase', + repo: 'changelog', + entriesPath: 'entries', + }) + const entries = getPublishedChangelogEntries(files) + + const rssEntries = entries.map((entry) => ({ + slug: entry.slug, + title: entry.frontmatter.title, + sortDate: entry.sortDate, + affectedProducts: entry.frontmatter.affected_products ?? [], + })) + + const changelogXml = generateChangelogRssXml(rssEntries) + const changelogRssPath = path.join(__dirname, '../public/changelog-rss.xml') + await fs.writeFile(changelogRssPath, changelogXml.trim(), 'utf8') + console.log(`✅ Generated changelog RSS with ${entries.length} entries`) + + // Per-tag feeds → public/changelog-rss/.xml + const productTagsPath = path.join(__dirname, '../data/changelog-product-tags.json') + const productTags = JSON.parse(await fs.readFile(productTagsPath, 'utf8')) + const tagFeedsDir = path.join(__dirname, '../public/changelog-rss') + // Clear first so a renamed/removed product tag doesn't leave a stale feed file behind. + await fs.rm(tagFeedsDir, { recursive: true, force: true }) + await fs.mkdir(tagFeedsDir, { recursive: true }) + const tagFilenames = productTags.map(({ label }) => `${labelToFileSlug(label)}.xml`) + const tagResults = await Promise.allSettled( + productTags.map(async ({ label }) => { + const fileSlug = labelToFileSlug(label) + const tagXml = generateChangelogTagRssXml(rssEntries, { displayLabel: label }) + await fs.writeFile(path.join(tagFeedsDir, `${fileSlug}.xml`), tagXml.trim(), 'utf8') + }) + ) + const failedTagFeeds = tagResults.flatMap((result, i) => + result.status === 'rejected' ? [{ file: tagFilenames[i], reason: result.reason }] : [] + ) + const succeeded = tagResults.length - failedTagFeeds.length + console.log(`✅ Generated ${succeeded}/${productTags.length} per-tag changelog RSS feeds`) + if (failedTagFeeds.length > 0) { + for (const { file, reason } of failedTagFeeds) { + console.error(`Failed to write changelog-rss/${file}:`, reason) } - - const entries = collected.map((item) => ({ - number: item.number, - slug: changelogEntrySlug(item.number, item.title), - title: item.title, - url: item.url, - sortDate: discussionDisplayDate({ title: item.title, createdAt: item.createdAt }), - labels: (item.labels?.nodes ?? []).map((l) => l.name.toLowerCase()), - body: item.body ?? '', - })) - - const changelogXml = generateChangelogRssXml(entries) - const changelogRssPath = path.join(__dirname, '../public/changelog-rss.xml') - await fs.writeFile(changelogRssPath, changelogXml.trim(), 'utf8') - const visibleCount = entries.filter((e) => !e.title.includes('[d]')).length - console.log(`✅ Generated changelog RSS with ${visibleCount} entries`) - - // Per-tag feeds → public/changelog-rss/.xml - const productTagsPath = path.join(__dirname, '../data/changelog-product-tags.json') - const productTags = JSON.parse(await fs.readFile(productTagsPath, 'utf8')) - const tagFeedsDir = path.join(__dirname, '../public/changelog-rss') - await fs.mkdir(tagFeedsDir, { recursive: true }) - const tagResults = await Promise.allSettled( - productTags.map(async ({ slug, label }) => { - const fileSlug = labelToFileSlug(label) - const tagXml = generateChangelogTagRssXml(entries, { - githubLabelSlug: slug, - displayLabel: label, - }) - await fs.writeFile(path.join(tagFeedsDir, `${fileSlug}.xml`), tagXml.trim(), 'utf8') - }) + throw new Error( + `Failed to generate ${failedTagFeeds.length}/${productTags.length} per-tag changelog RSS feeds` ) - const succeeded = tagResults.filter((r) => r.status === 'fulfilled').length - console.log(`✅ Generated ${succeeded}/${productTags.length} per-tag changelog RSS feeds`) - - // LLM-friendly changelog markdown index (RSS remains canonical syndication format). - const visibleEntries = entries.filter((entry) => !entry.title.includes('[d]')) - - /** - * Extracts the first meaningful paragraph from a markdown body. - * Skips headings, code fences, HTML blocks, and empty lines. - */ - const extractSummary = (body) => { - if (!body) return '' - for (const para of body.split(/\n{2,}/)) { - const trimmed = para.trim() - if ( - !trimmed || - trimmed.startsWith('#') || - trimmed.startsWith('```') || - trimmed.startsWith('<') || - trimmed.startsWith('|') || - trimmed.startsWith('---') - ) continue - const oneLiner = trimmed.replace(/\n/g, ' ') - return oneLiner.length > 200 ? oneLiner.slice(0, 200).replace(/\s+\S*$/, '') + '…' : oneLiner - } - return '' - } + } - const mdSections = visibleEntries.map((entry) => { - const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : '' - const labels = (entry.labels ?? []).join(', ') - const meta = [date, labels, `[supabase.com/changelog/${entry.slug}](https://supabase.com/changelog/${entry.slug})`] - .filter(Boolean) - .join(' · ') - const summary = extractSummary(entry.body) - return [`## ${entry.title}`, meta, summary].filter(Boolean).join('\n\n') - }) - const changelogMd = `# Supabase Changelog\n\n${mdSections.join('\n\n---\n\n')}\n` - const changelogMdPath = path.join(__dirname, '../public/changelog.md') - await fs.writeFile(changelogMdPath, changelogMd, 'utf8') - console.log(`✅ Generated changelog.md (${visibleEntries.length} entries)`) - - // One markdown file per entry → /changelog/.md (same content shape as the web page body). - const changelogEntryMdDir = path.join(__dirname, '../public/changelog') - await fs.mkdir(changelogEntryMdDir, { recursive: true }) - for (const entry of visibleEntries) { - const published = dayjs(entry.sortDate).isValid() - ? dayjs(entry.sortDate).format('YYYY-MM-DD') - : '' - const titleLine = String(entry.title ?? '') - .replace(/\n/g, ' ') - .trim() - const labelsYaml = (entry.labels ?? []).map((l) => ` - ${l}`).join('\n') - const pageUrl = `https://supabase.com/changelog/${entry.slug}` - const entryMd = `--- -number: ${entry.number} + // LLM-friendly changelog markdown index (RSS remains canonical syndication format). + const mdSections = entries.map((entry) => { + const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : '' + const changeType = CHANGE_TYPE_LABELS[entry.frontmatter.change_type] ?? entry.frontmatter.change_type + const products = (entry.frontmatter.affected_products ?? []).join(', ') + const meta = [ + date, + changeType, + products, + `[supabase.com/changelog/${entry.slug}](https://supabase.com/changelog/${entry.slug})`, + ] + .filter(Boolean) + .join(' · ') + return [`## ${entry.frontmatter.title}`, meta, entry.summary].filter(Boolean).join('\n\n') + }) + const changelogMd = `# Supabase Changelog\n\n${mdSections.join('\n\n---\n\n')}\n` + const changelogMdPath = path.join(__dirname, '../public/changelog.md') + await fs.writeFile(changelogMdPath, changelogMd, 'utf8') + console.log(`✅ Generated changelog.md (${entries.length} entries)`) + + // One markdown file per entry → /changelog/.md (Body section only — internal notes never included). + const changelogEntryMdDir = path.join(__dirname, '../public/changelog') + // Clear first so a renamed/unpublished entry doesn't leave a stale file behind. + await fs.rm(changelogEntryMdDir, { recursive: true, force: true }) + await fs.mkdir(changelogEntryMdDir, { recursive: true }) + for (const entry of entries) { + const published = dayjs(entry.sortDate).isValid() + ? dayjs(entry.sortDate).format('YYYY-MM-DD') + : '' + const titleLine = String(entry.frontmatter.title ?? '') + .replace(/\n/g, ' ') + .trim() + const productsYaml = (entry.frontmatter.affected_products ?? []) + .map((p) => ` - ${p}`) + .join('\n') + const pageUrl = `https://supabase.com/changelog/${entry.slug}` + const entryMd = `--- slug: ${entry.slug} published: ${published} -discussion: ${entry.url} -labels: -${labelsYaml || ' []'} +change_type: ${entry.frontmatter.change_type} +affected_products: +${productsYaml || ' []'} page: ${pageUrl} --- # ${titleLine} -${entry.body ?? ''} +${entry.bodySection} ` - await fs.writeFile( - path.join(changelogEntryMdDir, `${entry.slug}.md`), - entryMd.trim() + '\n', - 'utf8' - ) - } - console.log(`✅ Generated changelog/*.md (${visibleEntries.length} files)`) + await fs.writeFile( + path.join(changelogEntryMdDir, `${entry.slug}.md`), + entryMd.trim() + '\n', + 'utf8' + ) } -} catch (error) { - console.warn('Error generating changelog RSS:', error) + console.log(`✅ Generated changelog/*.md (${entries.length} files)`) } +await generateChangelogContent() diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index e4ba1802468af..728b752ffc124 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -57,6 +57,9 @@ "GITHUB_CHANGELOG_APP_INSTALLATION_ID", "GITHUB_CHANGELOG_APP_REST_KEY", "GITHUB_CHANGELOG_APP_PRIVATE_KEY", + "CHANGELOG_SYNC_APP_ID", + "CHANGELOG_SYNC_APP_INSTALLATION_ID", + "CHANGELOG_SYNC_APP_PRIVATE_KEY", "NEXT_PUBLIC_EMAIL_ABUSE_URL", "EMAIL_ABUSE_SERVICE_KEY", "HUBSPOT_PORTAL_ID", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 783197513ee17..95702943339d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1932,6 +1932,9 @@ importers: tailwindcss: specifier: 'catalog:' version: 4.2.4 + tar-stream: + specifier: ^3.1.7 + version: 3.1.7 tsconfig: specifier: workspace:* version: link:../../packages/tsconfig diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 35a6e565bcd0d..6e8dd9078577d 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -377,6 +377,7 @@ allow_list = [ "[Ss]avepoint", "SDKs", "SQL", + "SQLSTATE", "SQLAlchemy", "SQLModel", "SQLite", @@ -493,6 +494,7 @@ allow_list = [ "stdout", "[Ss]ubnet(s)?", "[Ss]ubpage", + "[Ss]ubstring", "supabase-auth-ui", "supabase-csharp", "supabase-community",