diff --git a/.claude/skills/clickhouse-logs-queries/SKILL.md b/.claude/skills/clickhouse-logs-queries/SKILL.md new file mode 100644 index 00000000000..91960c73179 --- /dev/null +++ b/.claude/skills/clickhouse-logs-queries/SKILL.md @@ -0,0 +1,226 @@ +--- +name: clickhouse-logs-queries +description: >- + Write, review, and migrate Supabase logs queries against the ClickHouse-backed + `logs` table (the `logs.all.otel` analytics endpoint). Use this whenever a task + involves Logs Explorer SQL, the `log_attributes` map, querying a log `source` + (edge_logs, postgres_logs, auth_logs, etc.), translating an old BigQuery + `cross join unnest(metadata)` logs query to ClickHouse, or wiring analytics log + SQL in `apps/studio/data/logs` and `apps/studio/components/interfaces/Settings/Logs`. + Reach for it even when the user just says "logs query", "Logs Explorer", or + pastes a BigQuery logs query to convert, not only when they name ClickHouse. +--- + +# Querying Supabase logs (ClickHouse) + +Supabase logs live in a single ClickHouse `logs` table, served by the +`logs.all.otel` analytics endpoint. Every log line from every part of the stack +is one row in this table, tagged by a `source` column. This replaces the older +BigQuery model, where each service had its own table and fields were reached +through `cross join unnest(metadata)`. + +Two kinds of work use this skill, and they share the same SQL model: + +1. **Writing or reviewing a logs query** (in the Logs Explorer or anywhere a raw + ClickHouse logs query is needed). Start here in this file. +2. **Wiring a logs query in the Studio codebase** (branded analytics SQL, the + endpoint picker, the OTEL query builders). Read + [references/codebase-integration.md](references/codebase-integration.md). + +If you are converting an existing BigQuery logs query, read +[references/bigquery-migration.md](references/bigquery-migration.md) for the full +translation table. + +## The logs table + +Each row has a small set of real columns. Everything specific to a service lives +in `log_attributes`. + +| Column | Type | Notes | +| ---------------- | --------------------- | ----------------------------------------------------- | +| `id` | `String` | Unique log identifier. | +| `timestamp` | `DateTime64` (UTC) | When the log was produced. Order/compare it directly. | +| `event_message` | `String` | The raw log line. | +| `severity_text` | `String` | Log level, when the source sets one. | +| `source` | `String` | The service the log came from. Always filter on this. | +| `log_attributes` | `Map(String, String)` | Structured per-source fields, keyed by a dotted path. | + +`timestamp` is formatted like `2026-06-22T09:34:06.215000` (ISO 8601, microsecond +precision, no trailing `Z`). In the Logs Explorer the selected time range is +applied for you, so you rarely need to write a `timestamp` filter by hand. + +A minimal, well-formed query. Lead with a comment naming the query, filter by +`source`, and always `limit`: + +```sql +-- recent edge requests +select timestamp, event_message +from logs +where source = 'edge_logs' +order by timestamp desc +limit 100; +``` + +## Sources + +`source` selects the service. The common ones: + +- `edge_logs` — API gateway requests and responses +- `postgres_logs` — database statements and errors (also where pg_cron logs live) +- `auth_logs` — authentication and authorization activity +- `function_edge_logs` — edge function requests and responses +- `function_logs` — `console` output from inside edge functions +- `storage_logs` — object upload and retrieval activity +- `realtime_logs` — Realtime client connections +- `postgrest_logs`, `supavisor_logs`, `pgbouncer_logs` — mostly `id`, `timestamp`, `event_message` + +The Logs Explorer **Field Reference** drawer lists every source and the fields it +actually sets. When in doubt about a key, discover it from real data rather than +guessing (see below). + +## Reading fields from log_attributes + +`log_attributes` maps a string key to a string value. Read a field with bracket +access. There are no unnesting joins: + +```sql +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' +``` + +The key keeps the dotted path that BigQuery expressed through nested structs, with +the `metadata` root dropped: BigQuery `metadata.request.method` becomes +`log_attributes['request.method']`. Keep the full prefix — `request.cf.country` +is `log_attributes['request.cf.country']`, not `log_attributes['cf.country']`. + +Common keys by source: + +- `edge_logs`: `request.method`, `request.path`, `request.search`, `response.status_code`, `identifier` +- `postgres_logs`: `parsed.error_severity`, `parsed.detail`, `parsed.hint`, `parsed.query`, `identifier` +- `auth_logs`: `level`, `status`, `path`, `msg`, `error` +- `function_edge_logs`: `response.status_code`, `request.method`, `request.pathname`, `function_id`, `execution_id`, `execution_time_ms` +- `function_logs`: `event_type`, `function_id`, `execution_id`, `level` + +### Numeric fields are strings + +Map values are always strings. To compare or aggregate a numeric field, wrap it in +`toInt32OrZero`, which returns `0` for missing or non-numeric values so it never +errors on partial data: + +```sql +select count() as server_errors +from logs +where source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) between 500 and 599 +``` + +### Discover the keys a source sets + +Read `mapKeys` from recent rows rather than guessing key names: + +```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; +``` + +`arrayJoin(mapKeys(...))` flattens the map keys into one row per key so you can +rank them by frequency. (The Studio codebase does exactly this for the Field +Reference drawer and to feed real keys to the AI rewrite.) + +## ClickHouse vs BigQuery functions + +These are the substitutions that trip people up most: + +| Need | BigQuery | ClickHouse | +| ------------------ | ----------------------------- | -------------------------------------------- | +| Count rows | `count(*)` | `count()` | +| Regex match | `regexp_contains(x, 'p')` | `match(x, 'p')` | +| Substring match | `x like '%p%'` | `x ilike '%p%'` (case-insensitive) or `like` | +| Numeric coercion | `cast(x as int64)` | `toInt32OrZero(x)` | +| Read the timestamp | `cast(timestamp as datetime)` | `timestamp` (use the column directly) | +| Map keys | n/a (used unnest) | `mapKeys(log_attributes)` | + +The `logs.all.otel` analytics endpoint (and the Logs Explorer on top of it) +rejects `count(*)` and `select *` — use `count()` and list the columns you need. +(Raw ClickHouse supports both; this is a constraint of the logs query surface.) + +## Best practices + +These keep queries correct and cheap. Log tables are large; an unbounded scan +reads far more data than you need. + +- **Start every query with an identifying comment** (e.g. `-- errors since last deploy`). It labels the query in logs and review, and makes each of several queries in a file easy to tell apart. +- **Always include a `LIMIT`.** Even for aggregates while you iterate. +- **Always query `from logs where source = '...'`.** There is no per-service table (no `edge_logs`, `postgres_logs`, etc. table) — there is one `logs` table, and `source` scopes it to a service. Filtering by `source` is required, not just an optimization. +- **Keep the time range tight.** A smaller window returns results faster. +- **Filter on the real columns** (`source`, `timestamp`) before reaching into + `log_attributes`. +- **Order by `timestamp desc`** to see the most recent logs first. +- **Use `count()`**, not `count(*)` or `select *`. + +## Worked examples + +Requests by status code: + +```sql +select + toInt32OrZero(log_attributes['response.status_code']) as status, + count() as count +from logs +where source = 'edge_logs' +group by status +order by count desc +limit 50 +``` + +Auth errors: + +```sql +select timestamp, event_message, log_attributes['msg'] as message +from logs +where source = 'auth_logs' + and log_attributes['level'] in ('error', 'fatal') +order by timestamp desc +limit 100 +``` + +Search the raw message: + +```sql +select timestamp, event_message +from logs +where source = 'postgres_logs' + and event_message ilike '%deadlock%' +order by timestamp desc +limit 100 +``` + +Postgres errors grouped by severity (the canonical unnest-to-map conversion): + +```sql +select log_attributes['parsed.error_severity'] as severity, count() as count +from logs +where source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') +group by severity +order by count desc +limit 100 +``` + +## When the user pastes a BigQuery query + +Convert it rather than running it as-is. The mechanical steps (drop the +per-service table for `from logs where source = ...`, remove every +`cross join unnest(...)`, rewrite unnest-alias columns as `log_attributes['...']` +lookups, swap the functions above) are spelled out with a full before/after in +[references/bigquery-migration.md](references/bigquery-migration.md). The Logs +Explorer also has a built-in **Rewrite to ClickHouse** action that does this with +AI; point users to it for one-off conversions in the dashboard. diff --git a/.claude/skills/clickhouse-logs-queries/references/bigquery-migration.md b/.claude/skills/clickhouse-logs-queries/references/bigquery-migration.md new file mode 100644 index 00000000000..09d84f813b0 --- /dev/null +++ b/.claude/skills/clickhouse-logs-queries/references/bigquery-migration.md @@ -0,0 +1,93 @@ +# Migrating a BigQuery logs query to ClickHouse + +The old logs engine gave each service its own table and exposed structured fields +through repeated `cross join unnest(...)` over a nested `metadata` column. The +ClickHouse engine has one `logs` table and a flat `log_attributes` map. Conversion +is mechanical once you internalize the mapping. + +## The five steps + +1. **Replace the table with `logs` plus a `source` filter.** The old table name is + the `source` value: `from postgres_logs as t` becomes + `from logs where source = 'postgres_logs'`. Never select from a per-service + table name (`postgres_logs`, `edge_logs`, ...) on the ClickHouse engine. + - Exception: pg_cron logs live under `source = 'postgres_logs'`. + +2. **Remove every unnest join.** Delete `cross join unnest(metadata) as m`, + `cross join unnest(m.parsed) as p`, `left join unnest(...) on true`, and so on. + They have no equivalent — the data is already flat in the map. + +3. **Rewrite every unnest-alias column as a map lookup.** A field taken off + `unnest(metadata)` becomes `log_attributes['field']`. A field off a nested + struct like `unnest(m.parsed)` becomes `log_attributes['parsed.field']`: keep + the struct name as a dotted prefix, drop the `metadata` root and every alias. + +4. **Wrap numeric fields in `toInt32OrZero(...)`** before comparing or aggregating + them — map values are strings. + +5. **Swap BigQuery functions for ClickHouse ones** (see the table below). + +Preserve the original select list, filters, group by, order by, and limit intent +throughout. + +## Function substitutions + +| Need | BigQuery | ClickHouse | +| ---------------- | ----------------------------- | --------------------------- | +| Count rows | `count(*)` | `count()` | +| Regex match | `regexp_contains(x, 'p')` | `match(x, 'p')` | +| Substring match | `x like '%p%'` | `x ilike '%p%'` or `like` | +| Numeric coercion | `cast(x as int64)` | `toInt32OrZero(x)` | +| Timestamp value | `cast(timestamp as datetime)` | `timestamp` | +| All columns | `select *` | list the columns explicitly | + +## Full before/after + +BigQuery: + +```sql +select count(t.timestamp) as count, p.error_severity +from + postgres_logs as t + cross join unnest(metadata) as m + cross join unnest(m.parsed) as p +where p.error_severity in ('ERROR', 'FATAL', 'PANIC') +group by p.error_severity +order by count desc +limit 100; +``` + +ClickHouse: + +```sql +select count() as count, log_attributes['parsed.error_severity'] as error_severity +from logs +where source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') +group by log_attributes['parsed.error_severity'] +order by count desc +limit 100 +``` + +Notice: `count(t.timestamp)` became `count()`, the two unnest joins are gone, +`p.error_severity` became `log_attributes['parsed.error_severity']`, and the +`from`/`where` targets the single table. + +## Getting the keys right + +The most common conversion mistake is dropping a dotted prefix — writing +`log_attributes['x_real_ip']` when the real key is +`log_attributes['request.headers.x_real_ip']`, or `log_attributes['cf.country']` +instead of `log_attributes['request.cf.country']`. Do not invent or shorten keys. +When unsure, discover the real keys from data: + +```sql +select arrayJoin(mapKeys(log_attributes)) as key, count() as n +from logs +where source = 'edge_logs' +group by key +order by n desc +limit 200; +``` + +Then map each old nested path to the matching key exactly as it appears. diff --git a/.claude/skills/clickhouse-logs-queries/references/codebase-integration.md b/.claude/skills/clickhouse-logs-queries/references/codebase-integration.md new file mode 100644 index 00000000000..05c55fa9767 --- /dev/null +++ b/.claude/skills/clickhouse-logs-queries/references/codebase-integration.md @@ -0,0 +1,96 @@ +# Wiring a logs query in the Studio codebase + +This covers writing analytics log SQL inside `apps/studio` so it is safe, routed +to the right endpoint, and consistent with the existing OTEL builders. Read this +when you are editing TypeScript that builds or runs a logs query, not when you are +just writing a query in the Logs Explorer UI. + +## Branded SQL: never concatenate user input + +All analytics log SQL must be a `SafeLogSqlFragment`, built with the helpers in +`apps/studio/data/logs/safe-analytics-sql.ts`. This is enforced by eslint, and the +branding is what keeps interpolated values from becoming injection. The key +exports: + +- `safeSql\`...\``— a tagged template that only accepts`SafeLogSqlFragment`interpolations. Plain strings (and Postgres-branded`SafeSqlFragment`) are + rejected at compile time, so you cannot accidentally drop a raw value in. +- `analyticsLiteral(value)` — turns a string/number/boolean into a safely escaped + literal fragment (single quotes and backslashes are escaped). Use it for every + dynamic value, especially the `source`. +- `joinSqlFragments(fragments, separator)` — joins already-safe fragments with a + fixed structural separator (`' and '`, `', '`, etc.). +- `keyword(value, allowed)` — resolves a value against a compile-time allow-list of + fragments (e.g. an `AND`/`OR` operator). Returns the allow-listed fragment, never + the raw input. +- `quotedIdent(value)` — backtick-quotes a dotted identifier path after validating + each segment. + +There is intentionally no exported "raw" escape hatch. Compose with `safeSql` plus +these helpers. + +```ts +import { analyticsLiteral, safeSql } from 'data/logs/safe-analytics-sql' + +const source = 'edge_logs' +const sql = safeSql` + select timestamp, event_message + from logs + where source = ${analyticsLiteral(source)} + order by timestamp desc + limit 100 +` +``` + +## Pick the endpoint and builder by flag + +The ClickHouse path is gated by the `otelLegacyLogs` PostHog flag +(`useFlag('otelLegacyLogs')` from `common`). Keep the BigQuery path working when +the flag is off. Two helpers in `apps/studio/data/logs/logs-endpoint.ts` express +the split: + +- `logsAllEndpointUrl(useOtel)` — returns the `logs.all.otel` endpoint when + `useOtel`, otherwise the legacy `logs.all` endpoint. +- `pickLogsQueryBuilder(useOtel, otel, bq)` — picks between an OTEL builder and a + BigQuery builder while preserving the type. + +```ts +const useOtel = useFlag('otelLegacyLogs') +const builder = pickLogsQueryBuilder(useOtel, genDefaultQueryOtel, genDefaultQuery) +const endpoint = logsAllEndpointUrl(useOtel) +// include { otel: useOtel } in the React Query key so the two paths cache separately +``` + +Run the fragment through `executeAnalyticsSql` (`apps/studio/data/logs/execute-analytics-sql.ts`) +against that endpoint. + +## Follow the existing OTEL builders + +When you need a new query shape, mirror the generators in +`apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.ts` rather than +inventing a parallel style. They already encode the conventions: + +- `genDefaultQueryOtel`, `genCountQueryOtel`, `genChartQueryOtel`, + `genSingleLogQueryOtel` — the row/count/chart/single-log builders. They select + the real columns plus source-specific `log_attributes[...]` lookups aliased to + the leaf names the renderers expect. +- `mapOtelPreviewRow`, `mapOtelSingleLogToLegacy`, `otelTimestampToMicros` — the + JS normalization layer. `timestamp` must end up a microsecond number for the + pagination cursor and the renderers, so reuse `otelTimestampToMicros` rather + than parsing the ISO string yourself. + +The map-key discovery hook (`apps/studio/data/logs/otel-log-keys-query.ts`, +`fetchOtelLogKeys` / `useOtelLogKeysQuery`) is the canonical example of a small, +correctly-branded OTEL query — read it before writing a new one. + +## Checklist + +- [ ] Every dynamic value goes through `analyticsLiteral` (or another sanitizer), + never string concatenation. +- [ ] The query filters by `source` and includes a `LIMIT`. +- [ ] Numeric `log_attributes` values are wrapped in `toInt32OrZero`. +- [ ] The endpoint and builder are chosen with `logsAllEndpointUrl` / + `pickLogsQueryBuilder` off `useFlag('otelLegacyLogs')`. +- [ ] The React Query key distinguishes the OTEL and BigQuery paths. +- [ ] `timestamp` is normalized to micros for any row consumed by the table/cursor. +- [ ] There is a unit test asserting the generated SQL string (see + `Logs.utils.otel.test.ts` and `safe-analytics-sql.test.ts` for the pattern). diff --git a/.claude/skills/studio-e2e-tests/SKILL.md b/.claude/skills/studio-e2e-tests/SKILL.md index 1006981e42d..ecd48bc8376 100644 --- a/.claude/skills/studio-e2e-tests/SKILL.md +++ b/.claude/skills/studio-e2e-tests/SKILL.md @@ -88,7 +88,7 @@ test.describe.configure({ mode: 'serial' }) 3. **`getByText` with exact match** - Good for unique text ```typescript - page.getByText('Data API Access', { exact: true }) + page.getByText('Data API access', { exact: true }) ``` 4. **`locator` with CSS** - Use sparingly, more fragile diff --git a/.claude/skills/studio-ui-patterns/SKILL.md b/.claude/skills/studio-ui-patterns/SKILL.md index 2a67de2bee4..c929e767a13 100644 --- a/.claude/skills/studio-ui-patterns/SKILL.md +++ b/.claude/skills/studio-ui-patterns/SKILL.md @@ -125,3 +125,9 @@ Forms in sheets: - `layout="horizontal"` for wider sheets - `layout="vertical"` for narrow sheets (`size="sm"` or below) + +## Copy + +Source of truth: `apps/design-system/content/docs/copywriting.mdx` — sentence case, title case, proper nouns, voice and tone. + +When changing visible copy, grep `e2e/studio/` for the old string. diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000000..fcbb740a6fe --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,71 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +# Don't inherit organization-level settings (they're tuned for other repos); +# this config is self-contained and unset values use CodeRabbit defaults. +inheritance: false + +# Enrich linked issues with related code and potential solutions during review. +issue_enrichment: + auto_enrich: + enabled: true + +reviews: + # Skip machine-generated / vendored files (mirrors .prettierignore). Keeps + # reviews focused on hand-written code and preserves rate-limit budget on + # large codegen diffs. + path_filters: + - '!pnpm-lock.yaml' + - '!packages/api-types/types/**' # generated API types (api.d.ts, platform.d.ts) + - '!supabase/functions/common/database-types.ts' # generated by `pnpm generate:types` + - '!**/routeTree.gen.ts' # TanStack Router generated + - '!**/__generated__/**' + - '!apps/docs/features/docs/generated/**' + - '!apps/www/.generated/**' + - '!apps/design-system/__registry__/**' + - '!apps/ui-library/__registry__/**' + - '!apps/ui-library/public/r/**' # registry output + - '!packages/icons/__registry__/**' + - '!packages/icons/src/icons/**' # generated icon components + + # Targeted, path-scoped review guidance, version-controlled alongside the code. + path_instructions: + - path: 'packages/common/telemetry-constants.ts' + instructions: | + Strictly enforce event naming: [object]_[verb] in snake_case. Only approved + verbs: opened, clicked, submitted, created, removed, updated, retrieved, + intended, evaluated, added, enabled, disabled, copied, exposed, failed, + converted. Properties must be camelCase for new events (match existing + convention when adding to existing events). Flag any usage of + useSendEventMutation. Verify @group Events and @source JSDoc tags are + accurate. Check that new interfaces are added to the TelemetryEvent union type. + - path: 'apps/studio/components/**/!(*.test).tsx' # production components only, not tests + instructions: | + Only suggest adding PostHog event tracking (via useTrack from + lib/telemetry/track, [object]_[verb] snake_case) when a new user-facing + interaction is growth-relevant: e.g. first-use of a feature, onboarding steps, + project/org creation, upgrade/billing actions, enabling or disabling a product + feature, or any action that signals activation or retention. Do not suggest + tracking for: passive views, page loads, UI-only state changes (e.g. expanding + a panel, switching tabs in a settings page), developer/internal tooling + interactions, or interactions clearly unrelated to product adoption. + +# Applies our internal engineering skills (.claude/skills/) as CodeRabbit review +# guidelines. The skills are the single source of truth — they are consumed +# directly, with no copy of their content elsewhere. +# +# `applyTo` decouples where a guideline file lives from the code it governs. +# Without it, CodeRabbit scopes a guideline file to its own directory and below; +# our skills live in .claude/skills/, which contains no code, so they would never +# reach apps/studio. `applyTo` points them at the right paths instead. +knowledge_base: + code_guidelines: + filePatterns: + # Studio code conventions — React/TS, UI patterns, composition, data fetching, errors + - files: '.claude/skills/{studio-best-practices,studio-ui-patterns,vercel-composition-patterns,studio-queries,studio-error-handling}/SKILL.md' + applyTo: 'apps/studio/**/*.{ts,tsx}' + # Studio unit / component test conventions + - files: '.claude/skills/{studio-testing,studio-mock-api-tests}/SKILL.md' + applyTo: 'apps/studio/**/*.test.{ts,tsx}' + # Studio end-to-end (Playwright) test conventions + - files: '.claude/skills/studio-e2e-tests/SKILL.md' + applyTo: 'e2e/studio/**/*.spec.ts' diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 201d1b6dc87..c494d51aea7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,4 +19,4 @@ /apps/studio/components/interfaces/Organization/Documents/ @supabase/security /apps/studio/pages/new/index.tsx @supabase/security -/packages/shared-data/compute-disk-limits.ts @supabase/infra +/packages/shared-data/compute-disk-limits.ts @supabase/infra @supabase/platform diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 827b4111518..b002e4a7b99 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -54,6 +54,7 @@ Path-specific rules in `.github/instructions/`: - **Error Handling**: `studio-error-handling.instructions.md` — error classification, `ErrorMatcher` usage - **E2E Tests**: `studio-e2e-tests.instructions.md` — selector priority, anti-patterns (`waitForTimeout`, `force: true`) - **Composition Patterns**: `studio-composition-patterns.instructions.md` — avoid boolean props, use compound components +- **UI Copy**: `studio-copy.instructions.md` → `apps/design-system/content/docs/copywriting.mdx` - **shadcn/Radix Components**: `studio-shadcn-components.instructions.md` — accessibility handled by primitives, do not flag - **Keyboard Shortcuts**: `studio-shortcuts.instructions.md` — shortcut registry pattern, search-input escape handler, when to flag missing coverage diff --git a/.github/instructions/studio-copy.instructions.md b/.github/instructions/studio-copy.instructions.md new file mode 100644 index 00000000000..7b662e0cf4f --- /dev/null +++ b/.github/instructions/studio-copy.instructions.md @@ -0,0 +1,13 @@ +--- +applyTo: 'apps/studio/**' +--- + +# Studio UI Copy + +All comments are **advisory**. + +**Source of truth:** `apps/design-system/content/docs/copywriting.mdx` — read it before writing or reviewing user-facing Studio strings. + +## Agent checklist (not in the design doc) + +- When changing visible copy, grep `e2e/studio/` and `.github/instructions/` for the old string. diff --git a/.github/instructions/studio-e2e-tests.instructions.md b/.github/instructions/studio-e2e-tests.instructions.md index 58488df8d18..590512b176b 100644 --- a/.github/instructions/studio-e2e-tests.instructions.md +++ b/.github/instructions/studio-e2e-tests.instructions.md @@ -23,7 +23,7 @@ All comments are **advisory**. 3. **`getByText` with exact match** — good for unique text ```typescript - page.getByText('Data API Access', { exact: true }) + page.getByText('Data API access', { exact: true }) ``` 4. **`locator` with CSS** — use sparingly, more fragile diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml index 4dbf1f24b44..bbaddc5fb27 100644 --- a/.github/workflows/docs-tests.yml +++ b/.github/workflows/docs-tests.yml @@ -55,6 +55,13 @@ jobs: working-directory: apps/docs/spec run: make download.tsdoc.v2 + - name: Generate Dart reference dump + # Dart has no upstream TypeDoc dump; its gitignored dump is generated + # from the committed `supabase_dart_v2.yml` so the reference-content + # snapshot test has something to walk. + working-directory: apps/docs + run: pnpm run codegen:references:dart + - name: Run tests run: | touch .env diff --git a/.github/workflows/studio-e2e-test.yml b/.github/workflows/studio-e2e-test.yml index 73204845f78..62505564f76 100644 --- a/.github/workflows/studio-e2e-test.yml +++ b/.github/workflows/studio-e2e-test.yml @@ -80,10 +80,6 @@ jobs: restore-keys: | ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}- - - name: Reset supabase - if: steps.filter.outputs.studio == 'true' - run: rm -rf supabase && pnpm exec supabase init && mkdir supabase/functions - # Authenticate with AWS ECR to avoid rate limiting - name: configure aws credentials if: steps.filter.outputs.studio == 'true' && !github.event.pull_request.head.repo.fork diff --git a/.github/workflows/ui-patterns-tests.yml b/.github/workflows/ui-patterns-tests.yml index 4b75674a3d7..c1a20d90fa9 100644 --- a/.github/workflows/ui-patterns-tests.yml +++ b/.github/workflows/ui-patterns-tests.yml @@ -40,5 +40,13 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile + - name: Validate exports + run: | + pnpm --filter ui-patterns gen:exports + if ! git diff --ignore-space-at-eol --exit-code --quiet packages/ui-patterns/package.json; then + echo "Detected uncommitted changes after build. Run pnpm --filter ui-patterns gen:exports to fix it."; + git diff; + exit 1; + fi - name: Run tests run: pnpm run test:ui-patterns diff --git a/.gitignore b/.gitignore index 5691dd146de..ce23cae5706 100644 --- a/.gitignore +++ b/.gitignore @@ -112,7 +112,8 @@ next-env.d.ts # DynamoDB Local files .dynamodb/ -.vscode +.vscode/* +!.vscode/content-listing.code-snippets .idea .vercel diff --git a/.prettierignore b/.prettierignore index 8c41d148ff5..1f07211dd8f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,7 +21,12 @@ apps/studio/public apps/**/.turbo apps/docs/CONTRIBUTING.md apps/docs/__generated__ +# Generated by apps/docs/internals/generate-markdown-manifest.ts +apps/docs/lib/markdown-manifest.ts apps/design-system/__registry__ +# TanStack Router auto-generated route tree; the file header explicitly +# says to exclude it from formatters. +apps/studio/routeTree.gen.ts packages/icons/__registry__ packages/icons/src/icons/*.ts apps/ui-library/__registry__ diff --git a/.vscode/content-listing.code-snippets b/.vscode/content-listing.code-snippets new file mode 100644 index 00000000000..580eda912cd --- /dev/null +++ b/.vscode/content-listing.code-snippets @@ -0,0 +1,28 @@ +{ + "Content listing data export": { + "prefix": "cl-data", + "scope": "typescript", + "description": "ContentListingGroup export for overview listing blocks", + "body": [ + "export const ${1:topic}${2:Section}: ContentListingGroup = {", + " id: '${3:topic-section}',", + " heading: '${4:Section heading}',", + " description: '${5:Optional intro sentence}',", + " type: '${6|grid,list|}',", + " items: [", + " {", + " title: '${7:Link title}',", + " href: '${8:/guides/...}',", + " description: '${9:Short description}',", + " },", + " ],", + "}" + ] + }, + "Content listing inline MDX": { + "prefix": "cl-inline", + "scope": "markdown,mdx", + "description": "Inline ContentListings component in a guide MDX file", + "body": [""] + } +} diff --git a/SECURITY.md b/SECURITY.md deleted file mode 120000 index 65dd29a7e19..00000000000 --- a/SECURITY.md +++ /dev/null @@ -1 +0,0 @@ -apps/docs/public/.well-known/security.txt \ No newline at end of file diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 5fd983a108f..0e0dce2ff27 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -159,6 +159,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "admonition-button-split": { + name: "admonition-button-split", + type: "components:example", + registryDependencies: ["admonition","button","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/admonition-button-split")), + source: "", + files: ["registry/default/example/admonition-button-split.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "admonition-description-only": { name: "admonition-description-only", type: "components:example", @@ -368,6 +379,72 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "breadcrumb-demo": { + name: "breadcrumb-demo", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-demo")), + source: "", + files: ["registry/default/example/breadcrumb-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-separator": { + name: "breadcrumb-separator", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-separator")), + source: "", + files: ["registry/default/example/breadcrumb-separator.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-dropdown": { + name: "breadcrumb-dropdown", + type: "components:example", + registryDependencies: ["breadcrumb","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-dropdown")), + source: "", + files: ["registry/default/example/breadcrumb-dropdown.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-ellipsis": { + name: "breadcrumb-ellipsis", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-ellipsis")), + source: "", + files: ["registry/default/example/breadcrumb-ellipsis.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-link": { + name: "breadcrumb-link", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-link")), + source: "", + files: ["registry/default/example/breadcrumb-link.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-responsive": { + name: "breadcrumb-responsive", + type: "components:example", + registryDependencies: ["breadcrumb","button","drawer","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-responsive")), + source: "", + files: ["registry/default/example/breadcrumb-responsive.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "button-demo": { name: "button-demo", type: "components:example", @@ -511,6 +588,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "button-split-dropdown": { + name: "button-split-dropdown", + type: "components:example", + registryDependencies: ["button","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/button-split-dropdown")), + source: "", + files: ["registry/default/example/button-split-dropdown.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "calendar-demo": { name: "calendar-demo", type: "components:example", diff --git a/apps/design-system/components/code-fragment.tsx b/apps/design-system/components/code-fragment.tsx index ccf33703720..4eeb7ee7c7c 100644 --- a/apps/design-system/components/code-fragment.tsx +++ b/apps/design-system/components/code-fragment.tsx @@ -94,10 +94,10 @@ export function CodeFragment({ )} > {showGrid && ( -
+
)} {showDottedGrid && ( -
+
)}
{ComponentPreview}
diff --git a/apps/design-system/components/command-menu.tsx b/apps/design-system/components/command-menu.tsx index 89886d4a59e..e4731f2a055 100644 --- a/apps/design-system/components/command-menu.tsx +++ b/apps/design-system/components/command-menu.tsx @@ -78,7 +78,7 @@ export function CommandMenu({ ...props }: DialogProps) { Search Design System... - + No results found. {docsConfig.sidebarNav.map((group) => ( diff --git a/apps/design-system/components/component-preview.tsx b/apps/design-system/components/component-preview.tsx index 8689633cf91..04a5fc30ee1 100644 --- a/apps/design-system/components/component-preview.tsx +++ b/apps/design-system/components/component-preview.tsx @@ -101,10 +101,10 @@ export function ComponentPreview({ )} > {showGrid && ( -
+
)} {showDottedGrid && ( -
+
)}
{ComponentPreview}
{/*
*/} @@ -146,10 +146,10 @@ export function ComponentPreview({ })} > {showGrid && ( -
+
)} {showDottedGrid && ( -
+
)}
{ComponentPreview}
diff --git a/apps/design-system/components/theme-settings.tsx b/apps/design-system/components/theme-settings.tsx index 7b72bd9daab..19abbeef152 100644 --- a/apps/design-system/components/theme-settings.tsx +++ b/apps/design-system/components/theme-settings.tsx @@ -3,7 +3,7 @@ import { useTheme } from 'next-themes' import { useEffect, useState } from 'react' import SVG from 'react-inlinesvg' -import { RadioGroup, RadioGroupLargeItem, singleThemes, Theme } from 'ui' +import { RadioGroup, RadioGroupLargeItem, singleThemes } from 'ui' const ThemeSettings = () => { const [mounted, setMounted] = useState(false) @@ -33,7 +33,7 @@ const ThemeSettings = () => { value={theme} className="flex flex-wrap gap-3" > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( diff --git a/apps/design-system/components/theme-switcher-dropdown.tsx b/apps/design-system/components/theme-switcher-dropdown.tsx index 23b706a9c47..039f00a1911 100644 --- a/apps/design-system/components/theme-switcher-dropdown.tsx +++ b/apps/design-system/components/theme-switcher-dropdown.tsx @@ -13,7 +13,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, singleThemes, - Theme, } from 'ui' const ThemeSwitcherDropdown = () => { @@ -59,7 +58,7 @@ const ThemeSwitcherDropdown = () => { value={theme} onValueChange={(themeValue) => setTheme(themeValue)} > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( {theme.name} diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx index 7588d3262c8..43afbfad43f 100644 --- a/apps/design-system/content/docs/components/button.mdx +++ b/apps/design-system/content/docs/components/button.mdx @@ -105,7 +105,7 @@ Used for actions that are not as important as the primary action, or for actions -### Only an Icon +### Only an icon Displaying only an Icon in a button. @@ -115,12 +115,28 @@ Displaying only an Icon in a button. -### As Child +### As child Supports slot behavior with `asChild` prop. +### Split with dropdown + +Pair a button with a chevron `DropdownMenu` trigger when there are variations of the same action, or alternative ways to accomplish the same goal. The default or most likely option should be used on the exposed button. + +When secondary actions are related but distinct—not alternatives to the primary action—display the primary action as a button and place the rest in an overflow menu instead. See [Table multiple actions](./table#multiple-actions). + + + +The shared middle border is the tricky part. Do **not** use `border-l-0` on the chevron button — that drops the divider on hover/focus. Instead: + +- Primary: `rounded-r-none` and `hover:z-10` so its border stacks above the chevron on hover. +- Chevron trigger: `rounded-l-none`, `shrink-0`, `px-[4px] py-[5px]`, and `-ml-px` to overlap the adjacent border by one pixel. +- Chevron trigger only: `aria-label` describing the menu (the icon is decorative). + +Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions, also use `flex w-full @lg:w-auto` with `flex-1 @lg:flex-none` on the primary when `layout="responsive"`. + ## Accessibility [Keyboard focus](../accessibility#focus-management) is automatically handled: diff --git a/apps/design-system/content/docs/fragments/admonition.mdx b/apps/design-system/content/docs/fragments/admonition.mdx index 8c903e50ab8..3b4221424d0 100644 --- a/apps/design-system/content/docs/fragments/admonition.mdx +++ b/apps/design-system/content/docs/fragments/admonition.mdx @@ -46,6 +46,18 @@ Style the color of the button based upon the _context_ of the Admonition, not it Only ever use the `primary` (green) button `type` on a `default` Admonition. Even then—given that Admonition is an isolated callout—the `primary` button `type` should rarely be used. +### Split button with dropdown + +When a callout offers alternative ways to accomplish the same goal, use a [split button](../components/button#split-with-dropdown) instead of crowding the callout with multiple buttons. For related but distinct actions, use an overflow menu. See [Table multiple actions](../components/table#multiple-actions). + + + +In responsive Admonitions, wrap the pair in `flex w-full @lg:w-auto`, give the primary `flex-1 @lg:flex-none`, and keep the chevron `shrink-0`. + ## Examples -## Responsive +### Responsive Resize your browser to see the button(s) change `layout` based on the Admonition’s width. diff --git a/apps/design-system/package.json b/apps/design-system/package.json index 5f8269c8af0..089349e2223 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@hookform/resolvers": "^3.1.1", - "@tanstack/react-table": "^8.21.3", + "@tanstack/react-table": "catalog:", "contentlayer2": "0.4.6", "common": "workspace:*", "date-fns": "^2.30.0", diff --git a/apps/design-system/registry/default/example/admonition-button-split.tsx b/apps/design-system/registry/default/example/admonition-button-split.tsx new file mode 100644 index 00000000000..3432c96f5c5 --- /dev/null +++ b/apps/design-system/registry/default/example/admonition-button-split.tsx @@ -0,0 +1,60 @@ +import { ChevronDown } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +export default function AdmonitionButtonSplitDemo() { + return ( + + + + + + + + + + )} + + + + ) : null} + {items.slice(-ITEMS_TO_DISPLAY + 1).map((item, index) => ( + + {index > 0 ? : null} + + {item.href ? ( + + {item.label} + + ) : ( + + {item.label} + + )} + + + ))} + + + ) +} diff --git a/apps/design-system/registry/default/example/breadcrumb-separator.tsx b/apps/design-system/registry/default/example/breadcrumb-separator.tsx new file mode 100644 index 00000000000..9de9e3583eb --- /dev/null +++ b/apps/design-system/registry/default/example/breadcrumb-separator.tsx @@ -0,0 +1,38 @@ +import { Slash } from 'lucide-react' +import Link from 'next/link' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from 'ui' + +export default function BreadcrumbSeparatorDemo() { + return ( + + + + + Home + + + + + + + + Components + + + + + + + Breadcrumb + + + + ) +} diff --git a/apps/design-system/registry/default/example/button-split-dropdown.tsx b/apps/design-system/registry/default/example/button-split-dropdown.tsx new file mode 100644 index 00000000000..885e1bbebaa --- /dev/null +++ b/apps/design-system/registry/default/example/button-split-dropdown.tsx @@ -0,0 +1,37 @@ +import { ChevronDown } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +export default function ButtonSplitDropdownDemo() { + return ( +
+ + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/copy-empty-states.tsx b/apps/design-system/registry/default/example/copy-empty-states.tsx index 7008b7ef0cf..50f25046cb2 100644 --- a/apps/design-system/registry/default/example/copy-empty-states.tsx +++ b/apps/design-system/registry/default/example/copy-empty-states.tsx @@ -2,7 +2,7 @@ import { Key } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export default function CopyEmptyStates() { return ( diff --git a/apps/design-system/registry/default/example/drawer-demo.tsx b/apps/design-system/registry/default/example/drawer-demo.tsx index 22ce3dd8566..1208680a3d0 100644 --- a/apps/design-system/registry/default/example/drawer-demo.tsx +++ b/apps/design-system/registry/default/example/drawer-demo.tsx @@ -111,7 +111,7 @@ export default function DrawerDemo() { dataKey="goal" style={ { - fill: 'hsl(var(--foreground-default))', + fill: 'var(--foreground-default)', opacity: 0.9, } as React.CSSProperties } diff --git a/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx b/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx index 0d548e96489..9a2196a4669 100644 --- a/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx +++ b/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx @@ -1,6 +1,6 @@ import { Plus } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { PageSection, PageSectionAside, diff --git a/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx b/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx index cf33f0e62f3..09a85c5d057 100644 --- a/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx +++ b/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx @@ -1,6 +1,6 @@ import { Plus } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export default function EmptyStatePresentationalIcon() { return ( diff --git a/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx b/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx index ba9e158d59b..a25204ae1ca 100644 --- a/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx +++ b/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx @@ -1,7 +1,7 @@ import { BucketPlus } from 'icons' import { Plus } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export default function EmptyStatePresentationalIcon() { return ( diff --git a/apps/design-system/registry/default/example/filter-bar-demo.tsx b/apps/design-system/registry/default/example/filter-bar-demo.tsx index 497a1ddecd8..4bc48b1bfec 100644 --- a/apps/design-system/registry/default/example/filter-bar-demo.tsx +++ b/apps/design-system/registry/default/example/filter-bar-demo.tsx @@ -2,7 +2,7 @@ import { format } from 'date-fns' import { useState } from 'react' import { DateRange } from 'react-day-picker' import { Button, Calendar } from 'ui' -import { CustomOptionProps, FilterBar, FilterGroup } from 'ui-patterns' +import { CustomOptionProps, FilterBar, FilterGroup } from 'ui-patterns/FilterBar' function CustomDatePicker({ onChange, onCancel, search }: CustomOptionProps) { const [date, setDate] = useState( diff --git a/apps/design-system/registry/default/example/page-layout-edge-function.tsx b/apps/design-system/registry/default/example/page-layout-edge-function.tsx index 1a9aeeaa5bc..4377bee9ec1 100644 --- a/apps/design-system/registry/default/example/page-layout-edge-function.tsx +++ b/apps/design-system/registry/default/example/page-layout-edge-function.tsx @@ -150,7 +150,7 @@ const buildMockChartData = (intervalKey: string) => { const EXECUTION_TIME_CHART_CONFIG = { avg_execution_time: { label: 'Average Execution Time', - color: 'hsl(var(--foreground-default))', + color: 'var(--foreground-default)', }, max_execution_time: { label: 'Max Execution Time', @@ -485,7 +485,7 @@ function OverviewPage() { { y: averageExecutionTime, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} @@ -538,7 +538,7 @@ function OverviewPage() { { y: averageCpuTime, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} @@ -589,7 +589,7 @@ function OverviewPage() { { y: averageMemoryUsage, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} diff --git a/apps/design-system/registry/default/example/page-layout-logs-content.tsx b/apps/design-system/registry/default/example/page-layout-logs-content.tsx index 6826cb565fc..8879095879c 100644 --- a/apps/design-system/registry/default/example/page-layout-logs-content.tsx +++ b/apps/design-system/registry/default/example/page-layout-logs-content.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { FilterBar, isGroup, type FilterCondition, type FilterGroup } from 'ui-patterns' +import { FilterBar, isGroup, type FilterCondition, type FilterGroup } from 'ui-patterns/FilterBar' const logs = [ ['10:42:01.129', 'POST /hello-world', '200', '132ms', 'iad1'], diff --git a/apps/design-system/registry/default/example/status-code-demo.tsx b/apps/design-system/registry/default/example/status-code-demo.tsx index 4f41585f901..1b346fb47dc 100644 --- a/apps/design-system/registry/default/example/status-code-demo.tsx +++ b/apps/design-system/registry/default/example/status-code-demo.tsx @@ -1,4 +1,4 @@ -import { StatusCode } from 'ui-patterns' +import { StatusCode } from 'ui-patterns/StatusCode' export default function StatusCodeDemo() { return ( diff --git a/apps/design-system/registry/default/example/switch-form.tsx b/apps/design-system/registry/default/example/switch-form.tsx index 172b5c1f76f..89ce9eb3d2e 100644 --- a/apps/design-system/registry/default/example/switch-form.tsx +++ b/apps/design-system/registry/default/example/switch-form.tsx @@ -83,7 +83,7 @@ export default function SwitchForm() { /> - diff --git a/apps/design-system/registry/default/example/textarea-form.tsx b/apps/design-system/registry/default/example/textarea-form.tsx index 56c59f50332..609ad8f0613 100644 --- a/apps/design-system/registry/default/example/textarea-form.tsx +++ b/apps/design-system/registry/default/example/textarea-form.tsx @@ -65,7 +65,7 @@ export default function TextareaForm() { )} /> - diff --git a/apps/design-system/registry/default/example/toc-demo.tsx b/apps/design-system/registry/default/example/toc-demo.tsx index 6764c55119f..f62d944a7aa 100644 --- a/apps/design-system/registry/default/example/toc-demo.tsx +++ b/apps/design-system/registry/default/example/toc-demo.tsx @@ -1,7 +1,13 @@ 'use client' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' -import { AnchorProvider, Toc, TOCItems, TOCScrollArea, type AnchorProviderProps } from 'ui-patterns' +import { + AnchorProvider, + Toc, + TOCItems, + TOCScrollArea, + type AnchorProviderProps, +} from 'ui-patterns/Toc' export default function MultiSelectDemo() { return ( diff --git a/apps/design-system/registry/default/example/toc-single-demo.tsx b/apps/design-system/registry/default/example/toc-single-demo.tsx index bb010262eae..513b3e88c20 100644 --- a/apps/design-system/registry/default/example/toc-single-demo.tsx +++ b/apps/design-system/registry/default/example/toc-single-demo.tsx @@ -1,7 +1,13 @@ 'use client' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' -import { AnchorProvider, Toc, TOCItems, TOCScrollArea, type AnchorProviderProps } from 'ui-patterns' +import { + AnchorProvider, + Toc, + TOCItems, + TOCScrollArea, + type AnchorProviderProps, +} from 'ui-patterns/Toc' export default function MultiSelectDemo() { return ( diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 596c4fbb5a0..161841ca9ca 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -25,6 +25,12 @@ export const examples: Registry = [ registryDependencies: ['admonition'], files: ['example/admonition-button.tsx'], }, + { + name: 'admonition-button-split', + type: 'components:example', + registryDependencies: ['admonition', 'button', 'dropdown-menu'], + files: ['example/admonition-button-split.tsx'], + }, { name: 'admonition-description-only', type: 'components:example', @@ -139,42 +145,42 @@ export const examples: Registry = [ registryDependencies: ['badge'], files: ['example/badge-secondary.tsx'], }, - // { - // name: 'breadcrumb-demo', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-demo.tsx'], - // }, - // { - // name: 'breadcrumb-separator', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-separator.tsx'], - // }, - // { - // name: 'breadcrumb-dropdown', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-dropdown.tsx'], - // }, - // { - // name: 'breadcrumb-ellipsis', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-ellipsis.tsx'], - // }, - // { - // name: 'breadcrumb-link', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-link.tsx'], - // }, - // { - // name: 'breadcrumb-responsive', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-responsive.tsx'], - // }, + { + name: 'breadcrumb-demo', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-demo.tsx'], + }, + { + name: 'breadcrumb-separator', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-separator.tsx'], + }, + { + name: 'breadcrumb-dropdown', + type: 'components:example', + registryDependencies: ['breadcrumb', 'dropdown-menu'], + files: ['example/breadcrumb-dropdown.tsx'], + }, + { + name: 'breadcrumb-ellipsis', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-ellipsis.tsx'], + }, + { + name: 'breadcrumb-link', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-link.tsx'], + }, + { + name: 'breadcrumb-responsive', + type: 'components:example', + registryDependencies: ['breadcrumb', 'button', 'drawer', 'dropdown-menu'], + files: ['example/breadcrumb-responsive.tsx'], + }, { name: 'button-demo', type: 'components:example', @@ -253,6 +259,12 @@ export const examples: Registry = [ registryDependencies: ['button'], files: ['example/button-as-child.tsx'], }, + { + name: 'button-split-dropdown', + type: 'components:example', + registryDependencies: ['button', 'dropdown-menu'], + files: ['example/button-split-dropdown.tsx'], + }, { name: 'calendar-demo', type: 'components:example', diff --git a/apps/design-system/styles/code-block-variables.css b/apps/design-system/styles/code-block-variables.css index f1054071748..f12e15fb992 100644 --- a/apps/design-system/styles/code-block-variables.css +++ b/apps/design-system/styles/code-block-variables.css @@ -18,16 +18,16 @@ [data-theme='light'], .light { --code-token-keyword: #6b35dc; - --code-foreground: hsl(var(--foreground-light) / 1); + --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; --code-token-string: #f1a10d; --code-token-comment: #7e7e7e; - --code-token-parameter: hsl(var(--foreground-light) / 1); + --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; --code-token-string-expression: #f1a10d; - --code-token-punctuation: hsl(var(--foreground-light) / 1); - --code-token-link: hsl(var(--foreground-light) / 1); - --code-token-number: hsl(var(--foreground-light) / 1); + --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); + --code-token-link: oklch(from var(--foreground-light) l c h / 1); + --code-token-number: oklch(from var(--foreground-light) l c h / 1); --code-token-property: #15593b; --code-highlight-color: #1c1c1c; } diff --git a/apps/docs/CONTRIBUTING.md b/apps/docs/CONTRIBUTING.md index 6450a90c812..6a3027b1770 100644 --- a/apps/docs/CONTRIBUTING.md +++ b/apps/docs/CONTRIBUTING.md @@ -197,15 +197,42 @@ Keep code lines short to avoid scrolling. For example, you can split long shell Optionally specify a filename for the codeblock by including it after the opening backticks and language specifier: -```md +````md ```ts environment.ts + ``` +```` Optionally highlight lines by using `mark=${lineNumber}`. -```md +````md ```js mark=12:13 + ``` +```` + +### Content listings + +Overview and index pages use a single `` component for curated link sections such as "Get started", "Next steps", "Examples", or "Resources". Refer to [`storage.data.ts`](data/content-listings/storage.data.ts) and [`storage.mdx`](content/guides/storage.mdx) for a full example. + +**Prompt to add content listings:** + +```text +Add a content listing block for [TOPIC] / [SECTION] (for example, Storage / Examples). +Follow CONTRIBUTING § Content listings in apps/docs. +Copy structure from `storageGetStarted` in apps/docs/data/content-listings/storage.data.ts. +Pick a globally-unique kebab-case id like `[topic]-[section]`. +Run `pnpm test:local lib/content-listings.test.ts` from apps/docs. +``` + +**Manually add content listings:** + +1. Add or update a `ContentListingGroup` export in [`data/content-listings/[topic].data.ts`](data/content-listings/). The `id` field must be globally unique across all listing groups (e.g. `storage-get-started`, not just `get-started`) — it is used both as the lookup key and as the telemetry `listingId`. +2. Place the component inline in guide MDX, for example ``. Use a partial only when the block is reused or gated with `$Show` at the partial level. +3. Run `pnpm test:local lib/content-listings.test.ts` from `apps/docs`. + +Code snippets for manually adding content listings are available in [`.vscode/content-listing.code-snippets`](../../.vscode/content-listing.code-snippets): `cl-data` (data export with namespaced id) and `cl-inline` (MDX component). + ### Footnotes @@ -284,7 +311,7 @@ Don't nest lists more than two deep. 3. List item - List item - List item - + - Overly nested list item ``` diff --git a/apps/docs/app/guides/database/database-advisors/page.tsx b/apps/docs/app/guides/database/database-advisors/page.tsx index 56d9c89876b..946afb6d46c 100644 --- a/apps/docs/app/guides/database/database-advisors/page.tsx +++ b/apps/docs/app/guides/database/database-advisors/page.tsx @@ -11,7 +11,7 @@ import { SerializeOptions } from '~/types/next-mdx-remote-serialize' import { capitalize } from 'lodash-es' import rehypeSlug from 'rehype-slug' import { Heading } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' // We fetch these docs at build time from an external repo const org = 'supabase' diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index ebafbd690b7..c8236dcddff 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -24,7 +24,7 @@ import { notFound } from 'next/navigation' import rehypeSlug from 'rehype-slug' import emoji from 'remark-emoji' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' // We fetch these docs at build time from an external repo const org = 'supabase' @@ -223,6 +223,14 @@ const pageMap = [ }, remoteFile: 'logflare.md', }, + { + slug: 'mongodb', + meta: { + title: 'MongoDB', + dashboardIntegrationPath: undefined, + }, + remoteFile: 'mongodb.md', + }, { slug: 'mssql', meta: { diff --git a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx index 6e6bf5b3053..178f1f0dc37 100644 --- a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx +++ b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { GlassPanel } from 'ui-patterns' +import { GlassPanel } from 'ui-patterns/GlassPanel' import { getAiPrompts } from './AiPrompts.utils' diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index aea0eb429ee..e0a43504b95 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -4,6 +4,7 @@ import 'ui-patterns/ShimmeringLoader/index.css' import '../styles/globals.css' import '../styles/prism-okaidia.css' +import { SkipToContent } from '~/components/SkipToContent' import { GlobalProviders } from '~/features/app.providers' import { TopNavSkeleton } from '~/layouts/MainSkeleton' import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants' @@ -12,6 +13,8 @@ import { TelemetryTagManager } from 'common' import { genFaviconData } from 'common/MetaFavicons/app-router' import type { Metadata, Viewport } from 'next' +import { inter, manrope } from '@/fonts' + const { metadataApplicationName, metadataTitle } = getCustomContent([ 'metadata:application_name', 'metadata:title', @@ -50,8 +53,9 @@ const viewport: Viewport = { const RootLayout = ({ children }: { children: React.ReactNode }) => { return ( - + + {children} diff --git a/apps/docs/components/ContentListings/ContentListings.client.tsx b/apps/docs/components/ContentListings/ContentListings.client.tsx new file mode 100644 index 00000000000..323478ca22e --- /dev/null +++ b/apps/docs/components/ContentListings/ContentListings.client.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { ContentListingGroup, ContentListingItem } from '~/lib/content-listings.schema' +import { + getContentListingById, + getContentListingGroupLabel, + isExternalContentListingHref, +} from '~/lib/content-listings.utils' +import { useSendTelemetryEvent } from '~/lib/telemetry' +import Link from 'next/link' +import { useCallback } from 'react' +import { GlassPanel } from 'ui-patterns/GlassPanel' +import { Heading } from 'ui/src/components/CustomHTMLElements' + +const GRID_ITEM_CLASS = { + 2: 'col-span-12 md:col-span-6', + 3: 'col-span-12 md:col-span-4', + 4: 'col-span-12 md:col-span-3', +} as const + +function useContentListingClickHandler(group: ContentListingGroup) { + const sendTelemetryEvent = useSendTelemetryEvent() + const groupLabel = getContentListingGroupLabel(group) + + const trackClick = useCallback( + (item: ContentListingItem) => { + sendTelemetryEvent({ + action: 'docs_content_listing_clicked', + properties: { + targetPath: item.href, + linkTitle: item.title, + ...(groupLabel ? { groupTitle: groupLabel } : {}), + listingId: group.id, + }, + }) + }, + [sendTelemetryEvent, group.id, groupLabel] + ) + + return { trackClick } +} + +function ContentListingGroupHeading({ group }: { group: ContentListingGroup }) { + if (!group.heading) return null + + return {group.heading} +} + +function ContentListingsGroup({ group }: { group: ContentListingGroup }) { + const { trackClick } = useContentListingClickHandler(group) + const isGrid = group.type === 'grid' + const listClassName = isGrid ? 'grid md:grid-cols-12 gap-4' : 'list-disc pl-6 space-y-2' + const gridItemClassName = isGrid ? GRID_ITEM_CLASS[group.columns ?? 3] : undefined + + // Heading stays outside `not-prose` so it inherits the surrounding MDX prose + // typography. The list itself opts out so its explicit Tailwind layout wins. + return ( +
+ +
+ {group.description &&

{group.description}

} +
    + {group.items.map((item) => { + const external = isExternalContentListingHref(item.href) + const key = `${group.id}-${item.href}` + + if (isGrid) { + return ( +
  • + trackClick(item)} + target={external ? '_blank' : undefined} + > + + {item.description} + + +
  • + ) + } + + return ( +
  • + trackClick(item)} + target={external ? '_blank' : undefined} + > + {item.title}: {item.description} + +
  • + ) + })} +
+
+
+ ) +} + +export function ContentListings({ id }: { id: string }) { + const group = getContentListingById(id) + if (!group || !group.items.length) return null + + return ( +
+ +
+ ) +} diff --git a/apps/docs/components/ContentListings/index.tsx b/apps/docs/components/ContentListings/index.tsx new file mode 100644 index 00000000000..461a4ab891b --- /dev/null +++ b/apps/docs/components/ContentListings/index.tsx @@ -0,0 +1 @@ +export { ContentListings } from './ContentListings.client' diff --git a/apps/docs/components/DocsCoverLogo.tsx b/apps/docs/components/DocsCoverLogo.tsx index 8a66b35ea13..3b56575513f 100644 --- a/apps/docs/components/DocsCoverLogo.tsx +++ b/apps/docs/components/DocsCoverLogo.tsx @@ -1,7 +1,7 @@ 'use client' +import { domAnimation, LazyMotion, m } from 'framer-motion' import React, { type PropsWithChildren } from 'react' -import { LazyMotion, domAnimation, m } from 'framer-motion' const DocsCoverLogo = (props: PropsWithChildren) => { const pathMotionConfig = { @@ -47,7 +47,7 @@ const DocsCoverLogo = (props: PropsWithChildren) => { animate={pathMotionConfig.animate} transition={pathMotionConfig.transition as any} d="M125.25 191.383C167.828 191.383 202.344 156.867 202.344 114.289C202.344 71.7115 167.828 37.1953 125.25 37.1953C82.6724 37.1953 48.1562 71.7115 48.1562 114.289C48.1562 156.867 82.6724 191.383 125.25 191.383Z" - stroke="hsl(var(--foreground-light))" + stroke="var(--foreground-light)" strokeOpacity="0.1" strokeWidth="0.7" strokeMiterlimit="10" @@ -215,7 +215,7 @@ const DocsCoverLogo = (props: PropsWithChildren) => { animate={pathMotionConfig.animate} transition={pathMotionConfig.transition as any} d="M218.024 169.846H32.498" - stroke="hsl(var(--foreground-light))" + stroke="var(--foreground-light)" strokeOpacity="0.1" strokeWidth="0.7" strokeMiterlimit="10" @@ -291,8 +291,8 @@ const DocsCoverLogo = (props: PropsWithChildren) => { gradientUnits="userSpaceOnUse" gradientTransform="translate(125.451 114.82) rotate(90) scale(109.781 0.5)" > - - + + { y2="11.9994" gradientUnits="userSpaceOnUse" > - - + + { y2="204.984" gradientUnits="userSpaceOnUse" > - - + + { y2="13.9993" gradientUnits="userSpaceOnUse" > - - + + { y2="204.984" gradientUnits="userSpaceOnUse" > - - + + { y2="-7.94661" gradientUnits="userSpaceOnUse" > - - - + + + { y2="-3.99932" gradientUnits="userSpaceOnUse" > - - - + + + { y2="199.061" gradientUnits="userSpaceOnUse" > - - - + + + { gradientUnits="userSpaceOnUse" gradientTransform="translate(125.752 114.291) rotate(90) scale(109.271 0.5)" > - - + + { y2="6.31757" gradientUnits="userSpaceOnUse" > - - + + { y2="200" gradientUnits="userSpaceOnUse" > - - + + { y2="199.061" gradientUnits="userSpaceOnUse" > - - - + + + { gradientUnits="userSpaceOnUse" gradientTransform="translate(125.261 59.2344) rotate(90) scale(0.5 263.749)" > - - + + { y2="94.9749" gradientUnits="userSpaceOnUse" > - - - + + + { y2="131.43" gradientUnits="userSpaceOnUse" > - - - + + + { y2="1.99785" gradientUnits="userSpaceOnUse" > - - - + + + { const GlobalNavigationMenu: FC = () => { const activeLabel = useActiveMenuLabel(GLOBAL_MENU_ITEMS) const triggerClassName = - 'h-(--header-height) p-2 bg-transparent border-0 border-b-2 border-transparent font-normal rounded-none text-foreground-light hover:text-foreground data-open:text-foreground! data-radix-collection-item:focus-visible:ring-2 data-radix-collection-item:focus-visible:ring-foreground-lighter data-radix-collection-item:focus-visible:text-foreground h-full focus-visible:rounded-sm shadow-none! outline-hidden transition-all outline-0 focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-brand-600' + 'h-(--header-height) p-2 bg-transparent border-0 border-b-2 border-transparent font-normal rounded-none text-foreground-light hover:bg-transparent hover:text-foreground data-open:bg-transparent! data-open:text-foreground! data-radix-collection-item:focus-visible:ring-2 data-radix-collection-item:focus-visible:ring-foreground-lighter data-radix-collection-item:focus-visible:text-foreground h-full focus-visible:rounded-sm shadow-none! outline-hidden transition-all outline-0 focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-brand-600' return (
diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 3b07a4cf095..b2392b2da0a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1179,12 +1179,12 @@ export const database: NavMenuConstant = { items: [ { name: 'Overview', url: '/guides/database/replication' }, { - name: 'External replication', - url: '/guides/database/replication/external-replication-setup' as `/${string}`, + name: 'Pipelines', + url: '/guides/database/replication/pipelines' as `/${string}`, items: [ { name: 'Setting up', - url: '/guides/database/replication/external-replication-setup' as `/${string}`, + url: '/guides/database/replication/pipelines' as `/${string}`, items: [ { name: 'BigQuery', @@ -1194,9 +1194,9 @@ export const database: NavMenuConstant = { }, { name: 'Monitoring', - url: '/guides/database/replication/external-replication-monitoring' as `/${string}`, + url: '/guides/database/replication/pipelines-monitoring' as `/${string}`, }, - { name: 'FAQ', url: '/guides/database/replication/external-replication-faq' }, + { name: 'FAQ', url: '/guides/database/replication/pipelines-faq' }, ], }, { @@ -1420,6 +1420,10 @@ export const database: NavMenuConstant = { name: 'Logflare', url: '/guides/database/extensions/wrappers/logflare' as `/${string}`, }, + { + name: 'MongoDB', + url: '/guides/database/extensions/wrappers/mongodb' as `/${string}`, + }, { name: 'MSSQL', url: '/guides/database/extensions/wrappers/mssql' as `/${string}`, @@ -2096,6 +2100,7 @@ export const storage: NavMenuConstant = { items: [ { name: 'Fundamentals', url: '/guides/storage/cdn/fundamentals' }, { name: 'Smart CDN', url: '/guides/storage/cdn/smart-cdn' }, + { name: 'Purging Cache', url: '/guides/storage/cdn/purge-cdn-cache' }, { name: 'Metrics', url: '/guides/storage/cdn/metrics' }, ], }, @@ -2715,6 +2720,10 @@ export const platform: NavMenuConstant = { name: 'Restore to a new project', url: '/guides/platform/clone-project', }, + { + name: 'Project Pausing', + url: '/guides/platform/free-project-pausing' as `/${string}`, + }, { name: 'Single Sign-On', url: '/guides/platform/sso', diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 7c2101cd27b..2ca75620376 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -10,7 +10,8 @@ import Link from 'next/link' import type { FC } from 'react' import { memo, useState } from 'react' import { Button, buttonVariants, cn } from 'ui' -import { AuthenticatedDropdownMenu, CommandMenuTriggerInput } from 'ui-patterns' +import { AuthenticatedDropdownMenu } from 'ui-patterns/AuthenticatedDropdownMenu' +import { CommandMenuTriggerInput } from 'ui-patterns/CommandMenu' import { getCustomContent } from '../../../lib/custom-content/getCustomContent' import GlobalNavigationMenu from './GlobalNavigationMenu' @@ -47,6 +48,7 @@ const TopNavBar: FC = () => {
Search @@ -96,13 +98,7 @@ const HeaderLogo = memo(() => { const { navigationLogo } = getCustomContent(['navigation:logo']) return ( - + { setTheme(value) }} > - {themes + {singleThemes .filter((x) => x.value === 'light' || x.value === 'dark' || x.value === 'system') - .map((theme: Theme) => ( + .map((theme) => ( {theme.name} diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx index 364af234717..6b88f8ae343 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx @@ -6,16 +6,16 @@ import { Button_Shadcn_ as Button, cn, Command, - CommandGroup as CommandGroup, + CommandGroup, CommandInput, - CommandItem as CommandItem, - CommandList as CommandList, + CommandItem, + CommandList, Popover, PopoverContent, PopoverTrigger, ScrollArea, } from 'ui' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' export interface ComboBoxOption { id: string diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx index 5d1b080bde7..82f81048105 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx @@ -29,6 +29,7 @@ import { useProjectsInfiniteQuery, } from '~/lib/fetch/projects-infinite' import { retrieve, storeOrRemoveNull } from '~/lib/storage' +import { useSendTelemetryEvent } from '~/lib/telemetry' import { useOnLogout } from '~/lib/userAuth' import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsUserLoading } from 'common' import { Check, Copy } from 'lucide-react' @@ -389,12 +390,13 @@ function VariableView({ variable, className }: { variable: Variable; className?: } const { copied, handleCopy } = useCopy() + const sendTelemetryEvent = useSendTelemetryEvent() return ( <>
{ + handleCopy() + sendTelemetryEvent({ + action: 'docs_project_config_variables_copy_button_clicked', + properties: { variable }, + }) + }} aria-label="Copy" > - {copied ? : } + {copied ? : }
@@ -445,7 +453,7 @@ function LoginHint({ variable }: { variable: Variable }) { if (isUserLoading || isLoggedIn) return null return ( -

+

To get your {prettyFormatVariable[variable]},{' '} { + return ( + + ) +} + +export { SkipToContent } diff --git a/apps/docs/components/StepHikeCompact/index.tsx b/apps/docs/components/StepHikeCompact/index.tsx index a20e5f8d4bb..11491e5ab0b 100644 --- a/apps/docs/components/StepHikeCompact/index.tsx +++ b/apps/docs/components/StepHikeCompact/index.tsx @@ -80,7 +80,7 @@ const Step: FC> = ({ children, title, step }) => { const Details: FC> = ({ children, title, fullWidth = false }) => { return (

-

{title}

+

{title}

{children}
) diff --git a/apps/docs/content/_partials/ai/quickstart_hf_deployment.mdx b/apps/docs/content/_partials/ai/quickstart_hf_deployment.mdx index 595b1f8f25b..5cd2f61f7aa 100644 --- a/apps/docs/content/_partials/ai/quickstart_hf_deployment.mdx +++ b/apps/docs/content/_partials/ai/quickstart_hf_deployment.mdx @@ -2,4 +2,4 @@ If you have your own infrastructure for deploying Python apps, you can continue to use `vecs` as described in this guide. -Alternatively if you would like to quickly deploy using Supabase, check out our guide on using the [Hugging Face Inference API](/docs/guides/ai/hugging-face) in Edge Functions using TypeScript. +Alternatively if you would like to deploy using Supabase, check out our guide on using the [Hugging Face Inference API](/docs/guides/ai/hugging-face) in Edge Functions using TypeScript. diff --git a/apps/docs/content/_partials/api_rate_limits.mdx b/apps/docs/content/_partials/api_rate_limits.mdx index e72382fd1c6..c050a467f80 100644 --- a/apps/docs/content/_partials/api_rate_limits.mdx +++ b/apps/docs/content/_partials/api_rate_limits.mdx @@ -57,6 +57,12 @@ Some endpoints have stricter rate limits than the standard 120 requests per minu **Note:** The `GET /v1/projects/:ref/database/context` endpoint has dual rate limiting. You can make up to 10 requests per minute, but also no more than 1 request per second to prevent burst traffic. +Some endpoints have the standard 120 requests per minute but with different timeout durations: + +| Endpoint | Limit | Duration | Reason | +| -------------------------------------------- | ------------ | --------- | ---------------------------------------------------- | +| `POST /v1/projects/:ref/database/migrations` | 120 requests | 3 minutes | Database migrations may require more processing time | + ### Best practices - **Monitor rate limit headers** - Check the `X-RateLimit-Remaining` header to see how many requests you have left. When it approaches 0, slow down your requests to avoid hitting the limit. diff --git a/apps/docs/content/_partials/api_settings.mdx b/apps/docs/content/_partials/api_settings.mdx index b60a575b2e5..15da66146f3 100644 --- a/apps/docs/content/_partials/api_settings.mdx +++ b/apps/docs/content/_partials/api_settings.mdx @@ -1,10 +1,12 @@ ### Get API details -Now that you've created some database tables, you are ready to insert data using the auto-generated API. +To interact with data in database tables, you use the client libraries that wrap [the auto-generated Data API endpoints](/docs/guides/api), authenticating using the Project URL and key from [the project **Connect** dialog](/dashboard/project/\_?showConnect=true&connectTab={{ .tab }}&framework={{ .framework }}). -To do this, you need to get the Project URL and key from [the project **Connect** dialog](/dashboard/project/\_?showConnect=true&connectTab={{ .tab }}&framework={{ .framework }}). + + -[Read the API keys docs](/docs/guides/getting-started/api-keys) for a full explanation of all key types and their uses. + -<$Partial path="api_keys_deprecation.mdx" variables={{ "framework": "{{ .framework }}", "tab": "{{ .tab }}" }} -/> +[Read the API keys docs](/docs/guides/getting-started/api-keys) for a full explanation of all key types, their uses, and where to find them. + + diff --git a/apps/docs/content/_partials/api_settings_steps.mdx b/apps/docs/content/_partials/api_settings_steps.mdx deleted file mode 100644 index 19b199f746b..00000000000 --- a/apps/docs/content/_partials/api_settings_steps.mdx +++ /dev/null @@ -1,12 +0,0 @@ -{/* TODO: How to completely consolidate partials? */} - -### Get API details - -Now that you've created some database tables, you are ready to insert data using the auto-generated API. - -To do this, you need to get the Project URL and key from [the project **Connect** dialog](/dashboard/project/\_?showConnect=true&connectTab={{ .tab }}&framework={{ .framework }}). - -[Read the API keys docs](/docs/guides/getting-started/api-keys) for a full explanation of all key types and their uses. - -<$Partial path="api_keys_deprecation.mdx" variables={{ "framework": "{{ .framework }}", "tab": "{{ .tab }}" }} -/> diff --git a/apps/docs/content/_partials/db_pre_request_warning.mdx b/apps/docs/content/_partials/db_pre_request_warning.mdx index 0b61bc21917..5bf6a4fc56a 100644 --- a/apps/docs/content/_partials/db_pre_request_warning.mdx +++ b/apps/docs/content/_partials/db_pre_request_warning.mdx @@ -22,7 +22,7 @@ on todos for select using ( set_information() AND (select auth.uid()) = user_id ); ``` -This ensures the function is called when evaluating RLS policies for all products, not just Data API requests. +This ensures the function is called when evaluating RLS policies for all products, not only Data API requests. **Performance consideration:** diff --git a/apps/docs/content/_partials/kotlin_project_setup.mdx b/apps/docs/content/_partials/kotlin_project_setup.mdx index 91b95ad86ec..546c23d80bb 100644 --- a/apps/docs/content/_partials/kotlin_project_setup.mdx +++ b/apps/docs/content/_partials/kotlin_project_setup.mdx @@ -10,7 +10,7 @@ Before building, you must set up your Database and API with a new Project in Sup ### Set up the database schema -Now we are going to set up the database schema. You can just copy/paste the SQL from below and run it yourself. +Now we are going to set up the database schema. You can copy/paste the SQL from below and run it yourself. + +This default takes effect for new projects from July 9, 2026. + + diff --git a/apps/docs/content/_partials/postgres_installation.mdx b/apps/docs/content/_partials/postgres_installation.mdx index a116934ecdd..876b02094f3 100644 --- a/apps/docs/content/_partials/postgres_installation.mdx +++ b/apps/docs/content/_partials/postgres_installation.mdx @@ -25,7 +25,7 @@ Add the Postgres binary to your system PATH. - In Control Panel, under the Advanced tab of System Properties, click Environment Variables. Edit the Path variable by adding the path to the SQL binary you just installed. + In Control Panel, under the Advanced tab of System Properties, click Environment Variables. Edit the Path variable by adding the path to the SQL binary you installed. The path will look something like this, though it may differ slightly depending on your installed version: diff --git a/apps/docs/content/_partials/quickstart_db_setup.mdx b/apps/docs/content/_partials/quickstart_db_setup.mdx index eedf4167677..741f1c0c611 100644 --- a/apps/docs/content/_partials/quickstart_db_setup.mdx +++ b/apps/docs/content/_partials/quickstart_db_setup.mdx @@ -1,15 +1,53 @@ - +export const sqlSetup = ` -- Create the table + create table instruments ( + id bigint primary key generated always as identity, + name text not null + ); -Go to [database.new](https://database.new) and create a new Supabase project. + -- Insert sample data into the table + insert into instruments (name) + values + ('violin'), + ('viola'), + ('cello'); -Alternatively, you can create a project using the Management API: + -- Grant the privileges the role needs, which is read access + grant select on public.instruments to anon; - + -- Enable row level security for the table + alter table instruments enable row level security; - + -- Create a policy to allow the anon role to read from the instruments table + create policy "public can read instruments" + on public.instruments + for select to anon + using (true);` + +## 1. Create a Supabase project + +Before you can use Supabase, you need a Supabase project. You can create a project visually in the Dashboard or programmatically using [the Management API](/docs/reference/api/introduction). + + + + + +Create a new Supabase project from [the Dashboard of any organization](/dashboard/new/_) you belong to. + + + +You can also use [database.new](https://database.new) to create a new Supabase project. + + + + + + + +First, get your access token from the [**Account Settings > Access Tokens**](/dashboard/account/tokens) section of the Dashboard. + +Then create a project using [the Management API](/docs/reference/api/introduction): ```bash -# First, get your access token from https://supabase.com/dashboard/account/tokens export SUPABASE_ACCESS_TOKEN="your-access-token" # List your organizations to get the organization ID @@ -28,19 +66,36 @@ curl -X POST https://api.supabase.com/v1/projects \ }' ``` - + - + -When your project is up and running, go to the [**Table Editor**](/dashboard/project/_/editor) section of the Dashboard, create a new table and insert some data. Then in the [**Integrations > Data API**](/dashboard/project/_/integrations/data_api/settings) section of the Dashboard, expose the specific tables or functions you want to access. To automatically grant access for new tables and functions in `public`, enable **Default privileges for new entities**. +## 2. Set up your database -Alternatively, you can run the following snippet in your project's [SQL Editor](/dashboard/project/_/sql/new). +When your Supabase project is up and running, create an `instruments` table with some sample data. -This creates an `instruments` table with some sample data, sets a secure baseline by setting only the privileges each Postgres role needs, and adds [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for enhanced security for database data by default. +Then set a secure baseline by setting only the privileges each Postgres role needs, add [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for enhanced security for database data by default, and create an RLS policy to make the data in your table publicly readable. - +You can click this button to prefill all the SQL needed in [the SQL editor of your project](/dashboard/project/_/sql) in the Dashboard. - + + + + + + Run the following snippet in your project's [SQL Editor](/dashboard/project/_/sql/new). You can also choose the SQL and prefill it from the **Reference > Samples** menu at the side of the SQL Editor. ```sql SQL_EDITOR -- Create the table @@ -61,19 +116,7 @@ This creates an `instruments` table with some sample data, sets a secure baselin -- Enable row level security for the table alter table instruments enable row level security; - ``` - - - - -Create an RLS policy to make the data in your table publicly readable: - - - - - - ```sql SQL_EDITOR -- Create a policy to allow the anon role to read from the instruments table create policy "public can read instruments" on public.instruments @@ -81,4 +124,12 @@ Create an RLS policy to make the data in your table publicly readable: using (true); ``` - + + + + + + +If you disabled the Data API during project setup, enable it in the [**Integrations > Data API**](/dashboard/project/_/integrations/data_api/settings) section of the Dashboard and expose the specific tables or functions you want to access. To automatically grant access for new tables and functions in `public`, enable **Automatically expose new tables**. + + diff --git a/apps/docs/content/guides/ai/choosing-compute-addon.mdx b/apps/docs/content/guides/ai/choosing-compute-addon.mdx index e4f8c1ae905..9243663b8f2 100644 --- a/apps/docs/content/guides/ai/choosing-compute-addon.mdx +++ b/apps/docs/content/guides/ai/choosing-compute-addon.mdx @@ -120,8 +120,10 @@ It is possible to upload more vectors to a single table if Memory allows it (for +The chart below compares HNSW queries-per-second across compute sizes for different embedding dimensions. + multi database +The chart below plots requests-per-second against compute size. + multi database +The chart below shows how the HNSW build parameters `m` and `ef_construction` affect requests-per-second on the dbpedia dataset. + multi database +The chart below shows how the number of IVFFlat lists affects performance for one million vectors. + multi database +_The diagram above shows the vecs benchmark setup: a Python test runner uploads data, builds the index, and runs queries against pgvector._ + Each test is run for a minimum of 30-40 minutes. They include a series of experiments executed at different concurrency levels to measure the engine's performance under different load types. The results are then averaged. As a general recommendation, we suggest using a concurrency level of 5 or more for most workloads and 30 or more for high-load workloads. diff --git a/apps/docs/content/guides/ai/concepts.mdx b/apps/docs/content/guides/ai/concepts.mdx index 264cbcc9860..357581cc455 100644 --- a/apps/docs/content/guides/ai/concepts.mdx +++ b/apps/docs/content/guides/ai/concepts.mdx @@ -39,7 +39,14 @@ So how does this relate to embeddings? Embeddings compress discrete information (words & symbols) into distributed continuous-valued data (vectors). If we took our phrases from before and plot them on a chart, it might look something like this: -Vector similarity +The chart below plots example phrases as points. Phrases with similar meanings sit close together, and unrelated phrases sit far apart. + +A two-dimensional chart plotting example phrases as points, where phrases with similar meanings sit close together and unrelated phrases sit far apart. Phrases 1 and 2 would be plotted close to each other, since their meanings are similar. We would expect phrase 3 to live somewhere far away since it isn't related. If we had a fourth phrase, “Sally ate Swiss cheese”, this might exist somewhere between phrase 3 (cheese can go on sandwiches) and phrase 1 (mice like Swiss cheese). diff --git a/apps/docs/content/guides/ai/engineering-for-scale.mdx b/apps/docs/content/guides/ai/engineering-for-scale.mdx index a9c74970bd7..225e4c6c835 100644 --- a/apps/docs/content/guides/ai/engineering-for-scale.mdx +++ b/apps/docs/content/guides/ai/engineering-for-scale.mdx @@ -14,8 +14,10 @@ For small workloads, you can typically store your data in a single database. If you've used [Vecs](/docs/guides/ai/vecs-python-client) to create 3 different collections, you can expose collections to your web or mobile application using [views](/docs/guides/database/tables#views): +The diagram below shows a single database holding the three vector collections of `docs`, `posts`, and `images`. Each are exposed to your application through a view. + single database diff --git a/apps/docs/content/guides/ai/vector-columns.mdx b/apps/docs/content/guides/ai/vector-columns.mdx index 6ca1aa54955..d9797eed146 100644 --- a/apps/docs/content/guides/ai/vector-columns.mdx +++ b/apps/docs/content/guides/ai/vector-columns.mdx @@ -59,7 +59,7 @@ create table documents ( ); ``` -In the above SQL snippet, we create a `documents` table with a column called `embedding` (note this is just a regular Postgres column - you can name it whatever you like). We give the `embedding` column a `vector` data type with 384 dimensions. Change this to the number of dimensions produced by your embedding model. For example, if you are [generating embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings) using the open source [`gte-small`](https://huggingface.co/Supabase/gte-small) model, you would set this number to 384 since that model produces 384 dimensions. +In the SQL snippet above, we create a `documents` table with an `embedding` column. This is a standard Postgres column, so you can name it anything you like. The `embedding` column uses the `vector` data type with 384 dimensions. Change this number to match the dimensions your embedding model produces. For example, if you're [generating embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings) using the open source [`gte-small`](https://huggingface.co/Supabase/gte-small) model, set this to 384. @@ -73,6 +73,7 @@ In this example we'll generate a vector using Transformers.js, then store it in ```js import { pipeline } from '@huggingface/transformers' + const generateEmbedding = await pipeline('feature-extraction', 'Supabase/gte-small') const title = 'First post!' diff --git a/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx index 6390b7260bb..8ddcb7c0ab0 100644 --- a/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx +++ b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx @@ -75,8 +75,10 @@ The hierarchical aspect of HNSW builds off of the idea of skip lists. Skip lists are multi-layer linked lists. The bottom layer is a regular linked list connecting an ordered sequence of elements. Each new layer above removes some elements from the underlying layer (based on a fixed probability), producing a sparser subsequence that “skips” over elements. +The diagram below shows a multi-layer skip list. The bottom layer links every ordered element, and each layer above keeps a sparser subset that skips over elements. + visual of an example skip listLog the full `error` object, not just `error.message`. +Log the full `error` object, not only `error.message`. ## The recommended pattern diff --git a/apps/docs/content/guides/api/securing-your-api.mdx b/apps/docs/content/guides/api/securing-your-api.mdx index de875e9e599..894b2e8179b 100644 --- a/apps/docs/content/guides/api/securing-your-api.mdx +++ b/apps/docs/content/guides/api/securing-your-api.mdx @@ -104,7 +104,7 @@ Any table created through the Supabase Dashboard will have RLS enabled by defaul > -1. Go to the [Authentication > Policies](/dashboard/project/_/auth/policies) page in the Dashboard. +1. Go to the [Database > Policies](/dashboard/project/_/database/policies) page in the Dashboard. 2. Select **Enable RLS** to enable Row Level Security. diff --git a/apps/docs/content/guides/auth.mdx b/apps/docs/content/guides/auth.mdx index a500f15609c..fd4071f7e65 100644 --- a/apps/docs/content/guides/auth.mdx +++ b/apps/docs/content/guides/auth.mdx @@ -1,7 +1,7 @@ --- id: 'auth' title: 'Auth' -description: 'Use Supabase to Authenticate and Authorize your users.' +description: 'Use Supabase to authenticate and authorize your users.' subtitle: 'Use Supabase to authenticate and authorize your users.' tocVideo: '6ow_jW4epf8' --- @@ -27,19 +27,15 @@ Auth uses your project's Postgres database under the hood, storing user data and Auth also enables access control to your database's automatically generated [REST API](/docs/guides/api). When using Supabase SDKs, your data requests are automatically sent with the user's Auth Token. The Auth Token scopes database access on a row-by-row level when used along with [RLS policies](/docs/guides/database/postgres/row-level-security). + + <$Show if="authentication:show_providers"> <$Partial path="providers.mdx" /> <$Show if="billing:all"> -## Pricing - -Charges apply to Monthly Active Users (MAU), Monthly Active Third-Party Users (Third-Party MAU), and Monthly Active SSO Users (SSO MAU) and Advanced MFA Add-ons. For a detailed breakdown of how these charges are calculated, refer to the following pages: - -- [Pricing MAU](/docs/guides/platform/manage-your-usage/monthly-active-users) -- [Pricing Third-Party MAU](/docs/guides/platform/manage-your-usage/monthly-active-users-third-party) -- [Pricing SSO MAU](/docs/guides/platform/manage-your-usage/monthly-active-users-sso) -- [Advanced MFA - Phone](/docs/guides/platform/manage-your-usage/advanced-mfa-phone) - + + + diff --git a/apps/docs/content/guides/auth/auth-anonymous.mdx b/apps/docs/content/guides/auth/auth-anonymous.mdx index 8d1276750bb..0a284cc4c8e 100644 --- a/apps/docs/content/guides/auth/auth-anonymous.mdx +++ b/apps/docs/content/guides/auth/auth-anonymous.mdx @@ -8,7 +8,7 @@ subtitle: 'Create and use anonymous users to authenticate with Supabase' -Calling `signInAnonymously()` creates an anonymous user. It's just like a permanent user, except the user can't access their account if they sign out, clear browsing data, or use another device. +Calling `signInAnonymously()` creates an anonymous user. It behaves like a permanent user, except the user can't access their account if they sign out, clear browsing data, or use another device. Like permanent users, the `authenticated` Postgres role will be used when using the Data APIs to access your project. JWTs for these users will have an `is_anonymous` claim which you can use to distinguish in RLS policies. @@ -279,7 +279,7 @@ response = supabase.auth.link_identity({'provider': 'google'}) ## Access control -An anonymous user assumes the `authenticated` role just like a permanent user. You can use row-level security (RLS) policies to differentiate between an anonymous user and a permanent user by checking for the `is_anonymous` claim in the JWT returned by `auth.jwt()`: +An anonymous user assumes the `authenticated` role like a permanent user. You can use row-level security (RLS) policies to differentiate between an anonymous user and a permanent user by checking for the `is_anonymous` claim in the JWT returned by `auth.jwt()`: ```sql create policy "Only permanent users can post to the news feed" diff --git a/apps/docs/content/guides/auth/auth-email-passwordless.mdx b/apps/docs/content/guides/auth/auth-email-passwordless.mdx index 34e48a8b2b4..56532c6dcbf 100644 --- a/apps/docs/content/guides/auth/auth-email-passwordless.mdx +++ b/apps/docs/content/guides/auth/auth-email-passwordless.mdx @@ -87,7 +87,7 @@ Read the [Deep Linking Documentation](/docs/guides/auth/native-mobile-deep-linki ```dart Future signInWithEmail() async { - final AuthResponse res = await supabase.auth.signInWithOtp(email: 'valid.email@supabase.io'); + await supabase.auth.signInWithOtp(email: 'valid.email@supabase.io'); } ``` @@ -223,7 +223,7 @@ const { data, error } = await supabase.auth.signInWithOtp({ ```dart Future signInWithEmailOtp() async { - final AuthResponse res = await supabase.auth.signInWithOtp(email: 'valid.email@supabase.io'); + await supabase.auth.signInWithOtp(email: 'valid.email@supabase.io'); } ``` diff --git a/apps/docs/content/guides/auth/auth-email-templates.mdx b/apps/docs/content/guides/auth/auth-email-templates.mdx index 34fe78b1811..f963ef84647 100644 --- a/apps/docs/content/guides/auth/auth-email-templates.mdx +++ b/apps/docs/content/guides/auth/auth-email-templates.mdx @@ -49,7 +49,27 @@ The templating system provides the following variables for use: ## Editing email templates -On hosted Supabase projects, edit your email templates on the [Email Templates](/dashboard/project/_/auth/templates) page. On self-hosted projects or in local development, edit your [configuration files](/docs/guides/local-development/customizing-email-templates). +Where you edit templates depends on how you run Supabase. + +### Hosted projects + +Edit templates on the [Email Templates](/dashboard/project/_/auth/templates) page in the dashboard. The template builder uses the same [terminology](#terminology) and variables documented on this page. + +### Local development and self-hosted + +The dashboard template builder does not apply when running Supabase locally or self-hosted. Customize templates in `supabase/config.toml` and HTML files instead. + + + +See [Customizing email templates](/docs/guides/local-development/customizing-email-templates) for: + +- [`config.toml` keys and HTML file paths](/docs/guides/local-development/customizing-email-templates#configuring-templates) +- [Template variables](/docs/guides/local-development/customizing-email-templates#template-variables) — the same placeholders as the [terminology](#terminology) table above +- Default subjects and behavior for each [authentication](/docs/guides/local-development/customizing-email-templates#available-authentication-email-templates) and [security notification](/docs/guides/local-development/customizing-email-templates#available-security-notification-email-templates) template + +For production self-hosted deployments, see [Custom email templates](/docs/guides/self-hosting/custom-email-templates). + + You can also manage email templates using the Management API: diff --git a/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx index df2b1199cda..c76f9deadf8 100644 --- a/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx @@ -19,7 +19,7 @@ Supabase Auth will send a payload containing these fields to your hook: -Because the hook is ran just before the insertion into the database, this user will not be found in Postgres at the time the hook is called. +Because the hook runs immediately before insertion into the database, this user will not be found in Postgres at the time the hook is called. @@ -722,8 +722,8 @@ supabase functions new before-user-created-hook Add the following code to your edge function: ```ts -import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' import { createClient } from 'https://esm.sh/@supabase/supabase-js' +import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' const whSecret = Deno.env.get('BEFORE_USER_CREATED_HOOK_SECRET')?.replace('v1,whsec_', '') const supabaseUrl = Deno.env.get('SUPABASE_URL') diff --git a/apps/docs/content/guides/auth/auth-mfa.mdx b/apps/docs/content/guides/auth/auth-mfa.mdx index 542884c5aa8..0c55ea6265e 100644 --- a/apps/docs/content/guides/auth/auth-mfa.mdx +++ b/apps/docs/content/guides/auth/auth-mfa.mdx @@ -271,7 +271,7 @@ It is possible to enforce MFA on the Server-Side Rendering level. However, this You can use the `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` and `supabase.auth.mfa.listFactors()` APIs to identify the AAL level of the session and any factors that are enabled for a user, similar to how you would use these on the browser. -However, encountering a different AAL level on the server may not actually be a security problem. Consider these likely scenarios: +However, encountering a different AAL level on the server may not be a security problem. Consider these likely scenarios: 1. User signed-in with a conventional method but closed their tab on the MFA flow. @@ -284,7 +284,7 @@ We thus recommend you redirect users to a page where they can authenticate using ### APIs -If your application uses the Supabase Database, Storage or Edge Functions, just using Row Level Security policies will give you sufficient protection. In the event that you have other APIs that you wish to protect, follow these general guidelines: +If your application uses the Supabase Database, Storage or Edge Functions, Row Level Security policies provide sufficient protection. In the event that you have other APIs that you wish to protect, follow these general guidelines: 1. **Use a good JWT verification and parsing library for your language.** This will let you securely parse JWTs and extract their claims. diff --git a/apps/docs/content/guides/auth/auth-mfa/phone.mdx b/apps/docs/content/guides/auth/auth-mfa/phone.mdx index c903dd95165..2bf410157f7 100644 --- a/apps/docs/content/guides/auth/auth-mfa/phone.mdx +++ b/apps/docs/content/guides/auth/auth-mfa/phone.mdx @@ -12,23 +12,36 @@ The phone messaging configuration for MFA is shared with [phone auth login](/doc Below is a flow chart illustrating how the Enrollment and Verify APIs work in the context of MFA (Phone). -Diagram showing the flow of Multi-Factor authentication +```mermaid +flowchart TD + InitS((Setup flow)) --> SAAL1[/Session is AAL1/] + SAAL1 --> Enroll[Enroll API] + Enroll --> ChallengeAPI[Challenge API] + ChallengeAPI --> Scan[/Code sent to User/] + Scan --> Enter[User: Enter code] + Enter --> Verify[Verify API] + Verify --> Check{{Is code correct?}} + Check -->|Yes| AAL2[/Upgrade to AAL2/] + AAL2 --> Done((Done)) + Check -->|No| Enter + InitA((Login flow)) --> SignIn([User: Sign-in]) + SignIn --> AAL1[/Upgrade to AAL1/] + AAL1 --> ListFactors[List Factors API] + ListFactors -->|1 or more factors| OpenAuth([User: Select phone factor]) + OpenAuth --> Enter + ListFactors -->|0 factors| Setup[[Setup flow]] +``` + +In the **setup flow**, a session already at AAL1 calls the Enroll API followed by the Challenge API, which sends a code to the user over SMS or WhatsApp. The user enters the code, the Verify API checks it, and on success the session is upgraded to AAL2. An incorrect code returns the user to the code-entry step. + +In the **login flow**, the user signs in (upgrading the session to AAL1) and the List Factors API is called. If the user has one or more factors, they select their phone factor and enter the code that was sent, following the same Verify path to reach AAL2. If they have no factors enrolled, they are sent through the setup flow first. ### Add enrollment flow An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app: 1. Right after login or sign up. - This allows users quickly set up Multi Factor Authentication (MFA) post login or account creation. Where possible, encourage all users to set up MFA. Many applications offer this as an opt-in step in an + This allows users to set up Multi Factor Authentication (MFA) post login or account creation. Where possible, encourage all users to set up MFA. Many applications offer this as an opt-in step in an effort to reduce onboarding friction. 2. From within a settings page. Allows users to set up, disable or modify their MFA settings. diff --git a/apps/docs/content/guides/auth/auth-mfa/totp.mdx b/apps/docs/content/guides/auth/auth-mfa/totp.mdx index c1869fbb097..c45751d91e9 100644 --- a/apps/docs/content/guides/auth/auth-mfa/totp.mdx +++ b/apps/docs/content/guides/auth/auth-mfa/totp.mdx @@ -12,25 +12,42 @@ The use of a QR code was [initially introduced by Google Authenticator](https:// Below is a flow chart illustrating how the Enrollment, Challenge, and Verify APIs work in the context of MFA (TOTP). -Diagram showing the flow of Multi-Factor authentication +```mermaid +flowchart TD + InitS((Setup flow)) --> SAAL1[/Session is AAL1/] + SAAL1 --> Enroll[Enroll API] + Enroll --> ShowQR[Show QR code] + ShowQR --> Scan([User: Scan QR code in authenticator]) + Scan --> Enter([User: Enter code]) + Enter --> Verify[Challenge + Verify API] + Verify --> Check{{Is code correct?}} + Check -->|Yes| AAL2[/Upgrade to AAL2/] + AAL2 --> Done((Done)) + Check -->|No| Enter + InitA((Login flow)) --> SignIn([User: Sign-in]) + SignIn --> AAL1[/Upgrade to AAL1/] + AAL1 --> ListFactors[List Factors API] + ListFactors -->|1 or more factors| OpenAuth([User: Open authenticator]) + OpenAuth --> Enter + ListFactors -->|0 factors| Setup[[Setup flow]] +``` + +In the **setup flow**, a session already at AAL1 calls the Enroll API, which returns a QR code for the user to scan with their authenticator app. The user enters the generated code, the Challenge and Verify APIs check it, and on success the session is upgraded to AAL2. If the code is incorrect, the user is prompted to enter it again. + +In the **login flow**, the user signs in (upgrading the session to AAL1) and the List Factors API is called. If the user has one or more factors, they open their authenticator and enter a code, which follows the same Challenge and Verify path to reach AAL2. If they have no factors enrolled, they are sent through the setup flow first. + + [TOTP MFA API](/docs/reference/javascript/auth-mfa-api) is free to use and is enabled on all Supabase projects by default. + + ### Add enrollment flow An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app: 1. Right after login or sign up. - This lets users quickly set up MFA immediately after they log in or create an + This lets users set up MFA immediately after they log in or create an account. We recommend encouraging all users to set up MFA if that makes sense for your application. Many applications offer this as an opt-in step in an effort to reduce onboarding friction. @@ -294,29 +311,6 @@ function AuthMFA() { ## Frequently asked questions - - -What's inside the QR code?} -id="what-is-inside-the-qr-code" -> - - - -How long is the TOTP code valid for?} -id="how-long-is-the-totp-code-valid-for" -> +### How long is the TOTP code valid for? In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks. - - - - diff --git a/apps/docs/content/guides/auth/auth-smtp.mdx b/apps/docs/content/guides/auth/auth-smtp.mdx index b43e261bda7..7ff2e248828 100644 --- a/apps/docs/content/guides/auth/auth-smtp.mdx +++ b/apps/docs/content/guides/auth/auth-smtp.mdx @@ -118,7 +118,7 @@ This includes: **Have another SMTP service set up on stand-by.** -In case the primary SMTP service you're using is experiencing difficulty, or your account is under threat of being blocked due to spam, you have another service to quickly turn to. +In case the primary SMTP service you're using is experiencing difficulty, or your account is under threat of being blocked due to spam, you have another service to turn to. **Use consistent branding and focused content.** diff --git a/apps/docs/content/guides/auth/general-configuration.mdx b/apps/docs/content/guides/auth/general-configuration.mdx index 0e79c0625c7..fcb98df2791 100644 --- a/apps/docs/content/guides/auth/general-configuration.mdx +++ b/apps/docs/content/guides/auth/general-configuration.mdx @@ -6,7 +6,7 @@ subtitle: 'General configuration options for Supabase Auth' This section covers the [general configuration options](/dashboard/project/_/auth) for Supabase Auth. If you are looking for another type of configuration, you may be interested in one of the following sections: -- [Policies](/dashboard/project/_/auth/policies) to manage Row Level Security policies for your tables. +- [Policies](/dashboard/project/_/database/policies) to manage Row Level Security policies for your tables. - [Sign In / Providers](/dashboard/project/_/auth/providers) to configure authentication providers and login methods for your users. - [Third Party Auth](/dashboard/project/_/auth/third-party) to use third-party authentication (TPA) systems based on JWTs to access your project. - [Sessions](/dashboard/project/_/auth/sessions) to configure settings for user sessions and refresh tokens. diff --git a/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx index 7b890e169b6..e08ddeb5567 100644 --- a/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx +++ b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx @@ -120,7 +120,7 @@ With Deep Linking, you can configure this redirect to open a specific page. This - Go to your [auth settings](/dashboard/project/_/auth/url-configuration) page. - You need to enter your app redirect callback on `Additional Redirect URLs` field. - The redirect callback URL should have this format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. Here, `io.supabase.flutterquickstart://login-callback` is just an example, you can choose whatever you would like for `YOUR_SCHEME` and `YOUR_HOSTNAME` as long as the scheme is unique across the user's device. For this reason, typically a reverse domain of your website is used. + The redirect callback URL should have this format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. Here, `io.supabase.flutterquickstart://login-callback` is an example. You can choose whatever you would like for `YOUR_SCHEME` and `YOUR_HOSTNAME` as long as the scheme is unique across the user's device. For this reason, typically a reverse domain of your website is used. ![Supabase console deep link setting](/docs/img/deeplink-setting.png) @@ -314,7 +314,7 @@ With Deep Linking, you can configure this redirect to open a specific page. This 1. Go to your [auth settings](/dashboard/project/_/auth/url-configuration) page. 2. Enter your app redirect URL in the `Additional Redirect URLs` field. This is the URL that the user gets redirected to after clicking a magic link. - The redirect callback URL should have the format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. Here, `io.supabase.user-management://login-callback` is just an example. You can choose whatever you would like for `YOUR_SCHEME` and `YOUR_HOSTNAME` as long as the scheme is unique across the user's device. For this reason, typically a reverse domain of your website is used. + The redirect callback URL should have the format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. Here, `io.supabase.user-management://login-callback` is an example. You can choose whatever you would like for `YOUR_SCHEME` and `YOUR_HOSTNAME` as long as the scheme is unique across the user's device. For this reason, typically a reverse domain of your website is used. ![Supabase console deep link setting](/docs/img/deeplink-setting.png) @@ -356,7 +356,7 @@ With Deep Linking, you can configure this redirect to open a specific page. This 1. Go to your [auth settings](/dashboard/project/_/auth/url-configuration) page. 2. Enter your app redirect URL in the `Additional Redirect URLs` field. This is the URL that the user gets redirected to after clicking a magic link. - The redirect callback URL should have the format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. Here, `io.supabase.user-management://login-callback` is just an example. You can choose whatever you would like for `YOUR_SCHEME` and `YOUR_HOSTNAME` as long as the scheme is unique across the user's device. For this reason, typically a reverse domain of your website is used. + The redirect callback URL must have the format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. For example: `io.supabase.user-management://login-callback`. You can use any values for `YOUR_SCHEME` and `YOUR_HOSTNAME`, but the scheme must be unique across the user's device. For this reason, a reverse domain of your website is typically used. Now, edit the Android manifest to make sure the app opens when the user clicks on the magic link. diff --git a/apps/docs/content/guides/auth/oauth-server.mdx b/apps/docs/content/guides/auth/oauth-server.mdx index 31df7fae5fc..66c7790d320 100644 --- a/apps/docs/content/guides/auth/oauth-server.mdx +++ b/apps/docs/content/guides/auth/oauth-server.mdx @@ -3,7 +3,7 @@ title: 'OAuth 2.1 Server' description: 'Turn your Supabase project into an OAuth 2.1 and OpenID Connect identity provider' --- -Supabase Auth can act as an OAuth 2.1 and OpenID Connect (OIDC) identity provider. This allows other applications and services to use your Supabase project as their authentication provider, just like "Sign in with Google" or "Sign in with GitHub". +Supabase Auth can act as an OAuth 2.1 and OpenID Connect (OIDC) identity provider. This allows other applications and services to use your Supabase project as their authentication provider, like "Sign in with Google" or "Sign in with GitHub". You can use this to build "Sign in with [Your App]" experiences, authenticate AI agents through the Model Context Protocol (MCP), power developer platforms with third-party integrations, or implement standards-compliant enterprise SSO. diff --git a/apps/docs/content/guides/auth/oauth-server/mcp-authentication.mdx b/apps/docs/content/guides/auth/oauth-server/mcp-authentication.mdx index 051892f1ad5..7434553409f 100644 --- a/apps/docs/content/guides/auth/oauth-server/mcp-authentication.mdx +++ b/apps/docs/content/guides/auth/oauth-server/mcp-authentication.mdx @@ -79,13 +79,13 @@ When building your own MCP server, integrate with Supabase Auth to authenticate **Looking for an easier way to build MCP servers?** -[FastMCP](https://gofastmcp.com) provides a streamlined way to build MCP servers with built-in Supabase Auth integration. FastMCP handles OAuth configuration, token management, and authentication flows automatically, letting you focus on building your AI agent's functionality. Check out their [Supabase integration guide](https://gofastmcp.com/integrations/supabase#supabase-fastmcp) to get started quickly. +[FastMCP](https://gofastmcp.com) provides a streamlined way to build MCP servers with built-in Supabase Auth integration. FastMCP handles OAuth configuration, token management, and authentication flows automatically, letting you focus on building your AI agent's functionality. Check out their [Supabase integration guide](https://gofastmcp.com/integrations/supabase#supabase-fastmcp) to get started. ## Handling MCP tokens in your application -When your MCP server makes requests to your Supabase APIs on behalf of authenticated users, it will send access tokens issued by Supabase Auth, just like any other OAuth client. +When your MCP server makes requests to your Supabase APIs on behalf of authenticated users, it will send access tokens issued by Supabase Auth, like any other OAuth client. ### Validating MCP tokens diff --git a/apps/docs/content/guides/auth/oauth-server/token-security.mdx b/apps/docs/content/guides/auth/oauth-server/token-security.mdx index b5da836eb94..2fe88b440a2 100644 --- a/apps/docs/content/guides/auth/oauth-server/token-security.mdx +++ b/apps/docs/content/guides/auth/oauth-server/token-security.mdx @@ -368,7 +368,7 @@ SELECT * FROM user_data WHERE user_id = 'test-user-uuid'; RESET request.jwt.claims; ``` -Or use the Supabase Dashboard's [RLS policy tester](/dashboard/project/_/auth/policies). +Or use the Supabase Dashboard's [RLS policy tester](/dashboard/project/_/database/policies). ## Troubleshooting diff --git a/apps/docs/content/guides/auth/passkeys.mdx b/apps/docs/content/guides/auth/passkeys.mdx index 1a732f7cfa7..ce5df61be30 100644 --- a/apps/docs/content/guides/auth/passkeys.mdx +++ b/apps/docs/content/guides/auth/passkeys.mdx @@ -136,6 +136,14 @@ await Supabase.initialize( final supabase = Supabase.instance.client; ``` +`supabase_flutter` performs the server side of the WebAuthn ceremony for you and delegates the platform prompt (FaceID/TouchID/security key) to an authenticator you supply, instead of depending on a passkey plugin directly. Add a passkey plugin to your own app and pass its authenticator to `registerPasskey()` and `signInWithPasskey()`. The [`passkeys`](https://pub.dev/packages/passkeys) plugin's `PasskeyAuthenticator` implements the `PasskeyAuthenticatorInterface` these methods expect (since `passkeys` `2.21.0`), but you can pass any implementation of that interface: + +```dart +import 'package:passkeys/authenticator.dart'; + +final authenticator = PasskeyAuthenticator(); +``` + Platform setup that the library cannot do for you (Associated Domains on iOS/macOS, Digital Asset Links on Android, and including the [`passkeys`](https://pub.dev/packages/passkeys) web SDK in `index.html` on web) is documented in the `supabase_flutter` package README. @@ -185,7 +193,7 @@ if (error) { ```dart try { - final Passkey passkey = await supabase.auth.registerPasskey(); + final Passkey passkey = await supabase.auth.registerPasskey(authenticator); print('Registered passkey ${passkey.id}'); } on AuthException catch (e) { // The Supabase server rejected the credential. @@ -261,7 +269,7 @@ if (error) { ```dart try { - final AuthResponse res = await supabase.auth.signInWithPasskey(); + final AuthResponse res = await supabase.auth.signInWithPasskey(authenticator); // res.session and res.user are set; the client also fires AuthChangeEvent.signedIn print('Signed in as ${res.user?.email}'); } on AuthException catch (e) { diff --git a/apps/docs/content/guides/auth/passwords.mdx b/apps/docs/content/guides/auth/passwords.mdx index 4548d785ba8..198c8e8f983 100644 --- a/apps/docs/content/guides/auth/passwords.mdx +++ b/apps/docs/content/guides/auth/passwords.mdx @@ -28,9 +28,8 @@ The instructions in this section assume that email confirmations are enabled. stickyTabList={{ style: { top: 'var(--header-height)', - backgroundColor: 'hsl(var(--background-default) / var(--tw-bg-opacity))', maskImage: 'none', - borderBottom: '1px solid hsl(var(--border-default) / 1)', + borderBottom: '1px solid oklch(from var(--border-default) l c h / 1)', } }} size="large" diff --git a/apps/docs/content/guides/auth/quickstarts/astrojs.mdx b/apps/docs/content/guides/auth/quickstarts/astrojs.mdx index e0de5fba90f..1baa7521c43 100644 --- a/apps/docs/content/guides/auth/quickstarts/astrojs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/astrojs.mdx @@ -106,7 +106,7 @@ hideToc: true PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_key ``` - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "astro", "tab": "frameworks" }} /> + <$Partial path="api_settings.mdx" variables={{ "framework": "astro", "tab": "frameworks" }} /> diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 82116d56820..30912b80e7e 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -67,7 +67,7 @@ hideToc: true NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... key ``` - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} /> + <$Partial path="api_settings.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} /> @@ -95,5 +95,5 @@ hideToc: true ## Learn more -- [Setting up Server-Side Auth for Next.js](/docs/guides/auth/server-side/nextjs) for a Next.js deep dive +- [Setting up Server-Side Auth for Next.js](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=nextjs) for a Next.js deep dive - [Supabase Auth docs](/docs/guides/auth#authentication) for more Supabase authentication methods diff --git a/apps/docs/content/guides/auth/quickstarts/react-native.mdx b/apps/docs/content/guides/auth/quickstarts/react-native.mdx index 4318c0d3e3b..d8ed113a884 100644 --- a/apps/docs/content/guides/auth/quickstarts/react-native.mdx +++ b/apps/docs/content/guides/auth/quickstarts/react-native.mdx @@ -81,7 +81,7 @@ hideToc: true meta="name=lib/supabase.ts" /> - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "exporeactnative", "tab": "mobiles" }} /> + <$Partial path="api_settings.mdx" variables={{ "framework": "exporeactnative", "tab": "mobiles" }} /> diff --git a/apps/docs/content/guides/auth/quickstarts/react.mdx b/apps/docs/content/guides/auth/quickstarts/react.mdx index 7c99938fecd..63cc239e117 100644 --- a/apps/docs/content/guides/auth/quickstarts/react.mdx +++ b/apps/docs/content/guides/auth/quickstarts/react.mdx @@ -65,8 +65,7 @@ hideToc: true Rename `.env.example` to `.env.local` and populate with your Supabase connection variables: - - + <$Partial path="api_settings.mdx" variables={{ "framework": "react", "tab": "frameworks" }} /> @@ -77,7 +76,6 @@ hideToc: true lines={[[1, -1]]} meta="name=.env.local" /> - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "react", "tab": "frameworks" }} /> diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index 92ce3e4f2d2..3dfe963a7e5 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -41,10 +41,7 @@ pnpm add @supabase/supabase-js @supabase/ssr Create a `.env.local` file in the project root directory. In the file, set the project's Supabase URL and Key: - - - -<$Partial path="api_settings_steps.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} /> +<$Partial path="api_settings.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} /> diff --git a/apps/docs/content/guides/auth/signing-keys.mdx b/apps/docs/content/guides/auth/signing-keys.mdx index 2fcd8b3ce59..97518c9618a 100644 --- a/apps/docs/content/guides/auth/signing-keys.mdx +++ b/apps/docs/content/guides/auth/signing-keys.mdx @@ -74,31 +74,12 @@ Key rotation and revocation are one of the most important processes for maintain ### Lifetime of a signing key -
- -Diagram showing the state transitions of a signing key - -
- A newly created key starts off as standby, before being rotated into in use (becoming the current key) while the existing current key becomes previously used. At any point you can move a key from the previously used or revoked states back to being a standby key, and rotate to it. This gives you the confidence to revert back to an older key if you identify problems with the rotation, such as forgetting to update a component of your application that is relying on a specific key (for example, the legacy JWT secret). Each action on a key is reversible (except permanent deletion). -
- -
- | Action | Accepted JWT signatures | Description | | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Create a new key | Current key only, new key has not created any JWTs yet. | When you initially create a key, after choosing the signing algorithm or importing a private key you already have, it starts out in the standby state. If using an asymmetric key (RSA, Elliptic Curve) its public key will be available in the discovery endpoint. Supabase Auth does not use this key to create new JWTs. | @@ -170,7 +151,7 @@ This guarantee provides your application with close alignment with security comp If you wish to make your own JWTs or have access to the private key or shared secret used by Supabase, you can create a new JWT signing key by importing a private key or setting a shared secret yourself. -Use the [Supabase CLI](/docs/reference/cli/introduction) to quickly and securely generate a private key ready for import: +Use the [Supabase CLI](/docs/reference/cli/introduction) to securely generate a private key ready for import: ```sh supabase gen signing-key --algorithm ES256 @@ -247,7 +228,7 @@ This is to ensure you have the ability, should you need it, to go back to the le ### Why does revoking the legacy JWT secret require disabling of `anon` and `service_role` API keys? -Unfortunately `anon` and `service_role` are not just API keys, but are also valid JSON Web Tokens, signed by the legacy JWT secret. Revoking the legacy JWT secret means that your application no longer trusts any JWT signed with it. Therefore before you revoke the legacy JWT secret, you must disable the `anon` and `service_role` to ensure a consistent security setup. +Unfortunately `anon` and `service_role` are not only API keys, but are also valid JSON Web Tokens, signed by the legacy JWT secret. Revoking the legacy JWT secret means that your application no longer trusts any JWT signed with it. Therefore before you revoke the legacy JWT secret, you must disable the `anon` and `service_role` to ensure a consistent security setup. ### Using JWT-based `anon` key in a mobile, desktop, or CLI application and need to rotate a `service_role` JWT secret? diff --git a/apps/docs/content/guides/auth/social-login/auth-azure.mdx b/apps/docs/content/guides/auth/social-login/auth-azure.mdx index 0189baaad42..7e2a09b236c 100644 --- a/apps/docs/content/guides/auth/social-login/auth-azure.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-azure.mdx @@ -95,7 +95,7 @@ Configure this in the following way: - Select the _App registrations_ menu in Microsoft Entra ID on the Azure portal. - Select the OAuth app. - Select the _Manifest_ menu in the sidebar. -- Make a backup of the JSON just in case. +- Make a backup of the JSON in case you need it later. - Identify the `optionalClaims` key. - Edit it by specifying the following object: ```json diff --git a/apps/docs/content/guides/auth/users.mdx b/apps/docs/content/guides/auth/users.mdx index 52306d68b72..3157bbc88a1 100644 --- a/apps/docs/content/guides/auth/users.mdx +++ b/apps/docs/content/guides/auth/users.mdx @@ -21,7 +21,7 @@ See the [Anonymous Signins guide](/docs/guides/auth/auth-anonymous) to learn mor -Just like permanent users, anonymous users use the **authenticated** role for database access. +Like permanent users, anonymous users use the **authenticated** role for database access. The **anon** role is for those who aren't signed in at all and are not tied to any user ID. We refer to these as unauthenticated or public users. diff --git a/apps/docs/content/guides/database/connecting-to-postgres.mdx b/apps/docs/content/guides/database/connecting-to-postgres.mdx index 20e48681bd6..f18555a9a9f 100644 --- a/apps/docs/content/guides/database/connecting-to-postgres.mdx +++ b/apps/docs/content/guides/database/connecting-to-postgres.mdx @@ -315,14 +315,18 @@ Because the dedicated pooler is hosted on the same machine as your database, it See the [connection method matrix](#how-to-connect-to-your-postgres-databases) at the top of this page for a quick reference, or follow the decision flow in the diagram below to choose the right option for your environment. -Decision tree diagram showing when to connect directly to Postgres or use a connection pooler. B[Persistent Backend] + A --> C[Serverless / Edge] + B --> D{IPv6 Supported?
IPv4 Add-on?} + B --> E{IPv4 Needed?} + C --> H{IPv6 Supported?
IPv4 Add-on?} + C --> I{IPv4 Needed?} + D --> F[Use Direct Connection] + E --> G[Use Supavisor Session Mode] + H --> J[Use Dedicated Pooler PgBouncer Pro] + I --> K[Use Supavisor Transaction Mode] +``` -/> +The decision depends on where you connect from. For a **persistent backend**, use a direct connection if you can reach the database over IPv6 (or have the IPv4 add-on); otherwise use Supavisor in session mode. For **serverless or edge** environments, use the dedicated pooler (PgBouncer, Pro plan) when IPv6 or the IPv4 add-on is available, or Supavisor in transaction mode when you need IPv4. diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index 5e382e5cf11..cfedbc80a5d 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -119,7 +119,7 @@ Parameters marked with **Restart: Yes** cause the CLI to automatically restart y
-Use the examples below with `supabase --experimental --project-ref postgres-config update`: +Use the examples below with `supabase postgres-config update --project-ref --experimental`: | Parameter | Type | Restart | Example | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------- | --------------------------------------------- | @@ -153,6 +153,8 @@ Use the examples below with `supabase --experimental --project-ref Some Postgres settings are configurable through the [Management API](/docs/reference/api/v1-update-postgres-config) but not the CLI. These include logging settings such as `log_connections`. See [Postgres connection logging](/docs/guides/platform/postgres-connection-logging) for details. +`cron.log_statement` (which logs each [pg_cron](/docs/guides/database/extensions/pg_cron) job statement before it runs) is also Management API only. It is a `postmaster`-context parameter, so updating it **requires a database restart** to take effect. + #### Managing Postgres configuration with the CLI To start: @@ -163,26 +165,26 @@ To start: To update Postgres configurations, use the [`postgres config`](/docs/reference/cli/supabase-postgres-config) command: ```bash -supabase --experimental \ -postgres-config update --config shared_buffers=250MB \ ---project-ref +supabase postgres-config update --config shared_buffers=250MB \ +--project-ref \ +--experimental ``` By default, the CLI will merge any provided config overrides with any existing ones. The `--replace-existing-overrides` flag can be used to instead force all existing overrides to be replaced with the ones being provided: ```bash -supabase --experimental \ -postgres-config update --config max_parallel_workers=3 \ +supabase postgres-config update --config max_parallel_workers=3 \ --replace-existing-overrides \ ---project-ref +--project-ref \ +--experimental ``` To delete specific configuration overrides, use the `postgres-config delete` command: ```bash -supabase --experimental \ -postgres-config delete --config shared_buffers,work_mem \ ---project-ref +supabase postgres-config delete --config shared_buffers,work_mem \ +--project-ref \ +--experimental ``` By default, CLI v2 (≥ 2.0.0) checks the parameter’s context and requests the correct action (reload or restart): @@ -221,9 +223,9 @@ By default, Supabase ensures that this propagation is executed correctly. Howeve ```bash -supabase --experimental \ +supabase postgres-config delete --config shared_buffers --no-restart \ --project-ref \ -postgres-config delete --config shared_buffers --no-restart +--experimental ``` ### Resetting to default config diff --git a/apps/docs/content/guides/database/extensions/hypopg.mdx b/apps/docs/content/guides/database/extensions/hypopg.mdx index 6bea2618227..6863634ff8b 100644 --- a/apps/docs/content/guides/database/extensions/hypopg.mdx +++ b/apps/docs/content/guides/database/extensions/hypopg.mdx @@ -6,7 +6,7 @@ description: 'Quickly check if an index can be used without creating it.' `HypoPG` is Postgres extension for creating hypothetical/virtual indexes. HypoPG allows users to rapidly create hypothetical/virtual indexes that have no resource cost (CPU, disk, memory) that are visible to the Postgres query planner. -The motivation for HypoPG is to allow users to quickly search for an index to improve a slow query without consuming server resources or waiting for them to build. +The motivation for HypoPG is to allow users to search for an index to improve a slow query without consuming server resources or waiting for them to build. ## Enable the extension diff --git a/apps/docs/content/guides/database/extensions/pgaudit.mdx b/apps/docs/content/guides/database/extensions/pgaudit.mdx index 48c482aa12b..1f6ff73921a 100644 --- a/apps/docs/content/guides/database/extensions/pgaudit.mdx +++ b/apps/docs/content/guides/database/extensions/pgaudit.mdx @@ -193,7 +193,7 @@ You can then assign the role to monitor only approved object events, such as `se grant select on random_table to "some_audit_role"; ``` -With this privilege granted, PGAudit will record all select statements that reference the `random_table`, regardless of _who_ or _what_ actually initiated the event. All assignable privileges can be viewed in the [Postgres documentation](https://www.postgresql.org/docs/current/ddl-priv.html). +With this privilege granted, PGAudit will record all select statements that reference the `random_table`, regardless of _who_ or _what_ initiated the event. All assignable privileges can be viewed in the [Postgres documentation](https://www.postgresql.org/docs/current/ddl-priv.html). If you would no longer like to use object logging, you will need to unassign the `pgaudit.role` variable: diff --git a/apps/docs/content/guides/database/extensions/pgroonga.mdx b/apps/docs/content/guides/database/extensions/pgroonga.mdx index 68bdb78ea77..3e8f135c000 100644 --- a/apps/docs/content/guides/database/extensions/pgroonga.mdx +++ b/apps/docs/content/guides/database/extensions/pgroonga.mdx @@ -124,7 +124,7 @@ id | content ### Match all search words -To find all memos where content contains BOTH of the words `postgres` and `pgroonga`, we can just use space to separate each words: +To find all memos where content contains BOTH of the words `postgres` and `pgroonga`, we can use space to separate each words: {/* prettier-ignore */} ```sql diff --git a/apps/docs/content/guides/database/extensions/plv8.mdx b/apps/docs/content/guides/database/extensions/plv8.mdx index 190cdcf6d24..219f98acc81 100644 --- a/apps/docs/content/guides/database/extensions/plv8.mdx +++ b/apps/docs/content/guides/database/extensions/plv8.mdx @@ -55,7 +55,7 @@ Procedural languages are automatically installed within `pg_catalog`, so you don ## Create `plv8` functions -Functions written in `plv8` are written just like any other Postgres functions, only +Functions written in `plv8` are written like any other Postgres functions, only with the `language` identifier set to `plv8`. ```sql diff --git a/apps/docs/content/guides/database/extensions/postgis.mdx b/apps/docs/content/guides/database/extensions/postgis.mdx index 1e45230f2cf..c02e5f9c9ae 100644 --- a/apps/docs/content/guides/database/extensions/postgis.mdx +++ b/apps/docs/content/guides/database/extensions/postgis.mdx @@ -192,7 +192,7 @@ val data = supabase.from("restaurants").insert(listOf(
-Notice the order in which you pass the latitude and longitude. Longitude comes first, and is because longitude represents the x-axis of the location. Another thing to watch for is when inserting data from the client library, there is no comma between the two values, just a single space. +Notice the order in which you pass the latitude and longitude. Longitude comes first, and is because longitude represents the x-axis of the location. Another thing to watch for is when inserting data from the client library, there is no comma between the two values, only a single space. At this point, if you go into your Supabase dashboard and look at the data, you will notice that the value of the `location` column looks something like this. diff --git a/apps/docs/content/guides/database/extensions/wrappers/overview.mdx b/apps/docs/content/guides/database/extensions/wrappers/overview.mdx index 5695f1aa007..867129be896 100644 --- a/apps/docs/content/guides/database/extensions/wrappers/overview.mdx +++ b/apps/docs/content/guides/database/extensions/wrappers/overview.mdx @@ -65,7 +65,7 @@ where ts > (now() - interval '1 DAY'); This approach provides several benefits: -1. **Simplicity:** the Wrappers API is just SQL, so data engineers don't need to learn new tools and languages. +1. **Simplicity:** the Wrappers API is SQL, so data engineers don't need to learn new tools and languages. 1. **Save on time:** avoid setting up additional data pipelines. 1. **Save on Data Engineering costs:** less infrastructure to be managed. diff --git a/apps/docs/content/guides/database/import-data.mdx b/apps/docs/content/guides/database/import-data.mdx index ca2e330b508..0b0262bdc7a 100644 --- a/apps/docs/content/guides/database/import-data.mdx +++ b/apps/docs/content/guides/database/import-data.mdx @@ -5,7 +5,7 @@ title: 'Import data into Supabase' You can import data into Supabase in multiple ways. The best method depends on your data size and app requirements. -If you're working with small datasets in development, you can experiment quickly using CSV import in the Supabase dashboard. If you're working with a large dataset in production, you should plan your data import to minimize app latency and ensure data integrity. +If you're working with small datasets in development, you can experiment with CSV import in the Supabase dashboard. If you're working with a large dataset in production, you should plan your data import to minimize app latency and ensure data integrity. ## How to import data into Supabase diff --git a/apps/docs/content/guides/database/inspect.mdx b/apps/docs/content/guides/database/inspect.mdx index 965e83912a2..8d01413792c 100644 --- a/apps/docs/content/guides/database/inspect.mdx +++ b/apps/docs/content/guides/database/inspect.mdx @@ -47,7 +47,7 @@ Most inspection commands are Postgres agnostic. You can run inspection routines For example you can connect to your local Postgres instance: ``` -supabase --db-url postgresql://postgres:postgres@localhost:5432/postgres inspect db bloat +supabase inspect db bloat --db-url postgresql://postgres:postgres@localhost:5432/postgres ``` ### Connect to a Supabase instance @@ -250,9 +250,9 @@ Postgres has built in tooling to help you optimize poorly performing queries. Yo explain analyze ; ``` -When you include `analyze` in the explain statement, the database attempts to execute the query and provides a detailed query plan along with actual execution times. So, be careful using `explain analyze` with `insert`/`update`/`delete` queries, because the query will actually run, and could have unintended side-effects. +When you include `analyze` in the explain statement, the database attempts to execute the query and provides a detailed query plan along with actual execution times. So, be careful using `explain analyze` with `insert`/`update`/`delete` queries, because the query will run, and could have unintended side-effects. -If you run just `explain` without the `analyze` keyword, the database will only perform query planning without actually executing the query. This approach can be beneficial when you want to inspect the query plan without affecting the database or if you encounter timeouts in your queries. +If you run `explain` without the `analyze` keyword, the database will only perform query planning without executing the query. This approach can be beneficial when you want to inspect the query plan without affecting the database or if you encounter timeouts in your queries. Using the query plan analyzer to optimize your queries is a large topic, with a number of online resources available: diff --git a/apps/docs/content/guides/database/json.mdx b/apps/docs/content/guides/database/json.mdx index 9d90a37ab70..e4191087163 100644 --- a/apps/docs/content/guides/database/json.mdx +++ b/apps/docs/content/guides/database/json.mdx @@ -23,7 +23,7 @@ Don't go overboard with `json/jsonb` columns. They are a useful tool, but most o ## Create JSONB columns -`json/jsonb` is just another "data type" for Postgres columns. You can create a `jsonb` column in the same way you would create a `text` or `int` column: +`json/jsonb` is another "data type" for Postgres columns. You can create a `jsonb` column in the same way you would create a `text` or `int` column: - -You can work with a project's database in several ways: +Work with your project's database in the following ways: - Visually using the [**Table Editor**](/dashboard/project/_/editor) section of the Dashboard. - With query syntax using the [**SQL Editor**](/dashboard/project/_/sql) section of the Dashboard. -- Programmatically using a variety of methods. +- Programmatically using a variety of different methods. -Read the [Connect to your database](/docs/guides/database/connecting-to-postgres#how-to-connect-to-your-postgres-databases) guide for details on connection strings and options. + - - -The database is the foundation that Auth, Storage, Realtime, and Edge Functions are built on, and Supabase manages daily database backups and offers point-in-time recovery on paid plans. - -## Get started - -If you're new to the database section, these are the pages to read first: - -- **[Connect to your database](/docs/guides/database/connecting-to-postgres)**: Connection strings, the Supavisor connection pooler, and when to use direct, transaction, or session mode. -- **[Tables and data](/docs/guides/database/tables)**: Create tables and relationships, and edit rows from the Dashboard. -- **[Import data](/docs/guides/database/import-data)**: Load existing data from CSV files, `pg_dump`, or another Postgres database. -- **[Secure your data](/docs/guides/database/secure-data)**: Row Level Security (RLS) is how Supabase makes the database safe to query directly from the client. Read this before exposing any table to your app. -- **[Extensions](/docs/guides/database/extensions)**: Enable Postgres extensions — including `pgvector` for embeddings, `PostGIS` for geospatial data, and `pg_cron` for scheduled jobs — from the Dashboard. -- **[Run SQL commands](/dashboard/project/_/sql)**: Use the Dashboard's SQL Editor for ad-hoc queries and saved snippets. - -## Going further - -Once you've covered the basics, these guides help with other use cases and features: - -- **[Database functions](/docs/guides/database/functions)** and **[triggers](/docs/guides/database/postgres/triggers)**: Run logic inside the database in response to inserts, updates, or deletes. -- **[Database webhooks](/docs/guides/database/webhooks)**: Send row changes to an external HTTP endpoint. -- **[Replication and read replicas](/docs/guides/database/replication)**: Stream changes to other systems or read from a geographically closer replica. -- **[Backups](/docs/guides/platform/backups)**: Daily backups on every project, with point-in-time recovery on paid plans. Backups cover the database itself; objects stored through the Storage API are not included. -- **[Query performance and optimization](/docs/guides/database/query-optimization)**: Indexes, the query planner, and tools for finding slow queries. -- **[Roles and permissions](/docs/guides/database/postgres/roles)**: The Postgres roles Supabase ships with and how to add your own. + diff --git a/apps/docs/content/guides/database/partitions.mdx b/apps/docs/content/guides/database/partitions.mdx index 8b18ef2c070..0f6164c4922 100644 --- a/apps/docs/content/guides/database/partitions.mdx +++ b/apps/docs/content/guides/database/partitions.mdx @@ -6,8 +6,10 @@ description: 'Organizing tables into partitions in Postgres.' Table partitioning is a technique that allows you to divide a large table into smaller, more manageable parts called “partitions”. +The diagram below shows a single large table split into several smaller partitions, each holding a subset of its rows. + multi database @@ -31,7 +31,7 @@ For example, assume you have a `posts` table with the following columns: - `created_at` - `updated_at` -You can restrict updates to just the user who created it using [RLS](/docs/guides/auth#row-level-security), with the following policy: +You can restrict updates to the user who created it using [RLS](/docs/guides/auth#row-level-security), with the following policy: ```sql create policy "Allow update for owners" on posts for @@ -66,7 +66,7 @@ update (title, content) on table public.posts to authenticated; ``` -In the above example, we are revoking the table-level `UPDATE` privilege from the `authenticated` role and granting a column-level `UPDATE` privilege on just the `title` and `content` columns. +In the above example, we are revoking the table-level `UPDATE` privilege from the `authenticated` role and granting a column-level `UPDATE` privilege on the `title` and `content` columns. If we want to restrict access to updating the `title` column: diff --git a/apps/docs/content/guides/database/postgres/data-deletion.mdx b/apps/docs/content/guides/database/postgres/data-deletion.mdx index a27ac37074c..eecfd8f66cb 100644 --- a/apps/docs/content/guides/database/postgres/data-deletion.mdx +++ b/apps/docs/content/guides/database/postgres/data-deletion.mdx @@ -47,7 +47,7 @@ delete from logs where created_at < now() - interval '90 days'; ``` -This acquires a `ROW EXCLUSIVE` lock on the table, which still allows other `SELECT`, `INSERT`, `UPDATE`, and `DELETE` statements to run concurrently. For small row counts, the operation completes quickly and has minimal impact. +This acquires a `ROW EXCLUSIVE` lock on the table, which still allows other `SELECT`, `INSERT`, `UPDATE`, and `DELETE` statements to run concurrently. For small row counts, the operation completes with minimal impact. ### Large deletes @@ -188,7 +188,7 @@ If autovacuum is not keeping up, you can trigger a manual vacuum: vacuum (verbose) logs; ``` -For reclaiming disk space (not just marking tuples as reusable), use `VACUUM FULL` — but be aware this rewrites the entire table and takes an `ACCESS EXCLUSIVE` lock: +To reclaim disk space rather than only marking tuples as reusable, use `VACUUM FULL`. Note that this rewrites the entire table and takes an `ACCESS EXCLUSIVE` lock: ```sql -- This locks the table for the duration — use during maintenance windows only diff --git a/apps/docs/content/guides/database/postgres/event-triggers.mdx b/apps/docs/content/guides/database/postgres/event-triggers.mdx index 78da36e91e9..682b7d042a5 100644 --- a/apps/docs/content/guides/database/postgres/event-triggers.mdx +++ b/apps/docs/content/guides/database/postgres/event-triggers.mdx @@ -61,10 +61,10 @@ See how to [auto enable RLS for new tables](/docs/guides/database/postgres/row-l Event triggers can be triggered on: -- `ddl_command_start` - occurs just before a DDL command for almost all objects within a schema -- `ddl_command_end` - occurs just after a DDL command for almost all objects within a schema -- `sql_drop` - occurs just before `ddl_command_end` for any DDL commands that `DROP` a database object (note that altering a table can cause it to be dropped) -- `table_rewrite` - occurs just before a table is rewritten using the `ALTER TABLE` command +- `ddl_command_start` - occurs before a DDL command for almost all objects within a schema +- `ddl_command_end` - occurs after a DDL command for almost all objects within a schema +- `sql_drop` - occurs before `ddl_command_end` for any DDL commands that `DROP` a database object (note that altering a table can cause it to be dropped) +- `table_rewrite` - occurs before a table is rewritten using the `ALTER TABLE` command diff --git a/apps/docs/content/guides/database/postgres/indexes.mdx b/apps/docs/content/guides/database/postgres/indexes.mdx index 3263e5a6e6a..ec1c5a24724 100644 --- a/apps/docs/content/guides/database/postgres/indexes.mdx +++ b/apps/docs/content/guides/database/postgres/indexes.mdx @@ -5,7 +5,7 @@ footerHelpType: 'postgres' tocVideo: 'bBu_V8CfWgM' --- -An index makes your Postgres queries faster. The index is like a "table of contents" for your data - a reference list which allows queries to quickly locate a row in a given table without needing to scan the entire table (which in large tables can take a long time). +An index makes your Postgres queries faster. The index is like a "table of contents" for your data - a reference list which allows queries to locate a row in a given table without needing to scan the entire table (which in large tables can take a long time). Indexes can be structured in a few different ways. The type of index chosen depends on the values you are indexing. By far the most common index type, and the default in Postgres, is the B-Tree. A B-Tree is the generalized form of a binary search tree, where nodes can have more than two children. @@ -67,7 +67,7 @@ Luckily Postgres provides us with `create index concurrently` which prevents blo -Here is a simplified diagram of the index we just created (note that in practice, nodes actually have more than two children). +Here is a simplified diagram of the index we created (note that in practice, nodes have more than two children). B-Tree index example in Postgres diff --git a/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx b/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx index 4918a182eb2..b9e9ff75b14 100644 --- a/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx +++ b/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx @@ -72,7 +72,7 @@ Prisma is unable to allocate connections to pending queries fast enough to meet #### Possible causes: [#possible-causes-timed-out-fetching-a-new-connection] - **Overwhelmed server**: The server hosting Prisma is under heavy load, limiting its ability to manage connections. By default, Prisma will create the default `num_cpus * 2 + 1` worth of connections. A common cause for server strain is increasing the `connection_limit` significantly past the default. -- **Insufficient pool size**: The Supavisor pooler does not have enough connections available to quickly satisfy Prisma's requests. +- **Insufficient pool size**: The Supavisor pooler does not have enough connections available to satisfy Prisma's requests. - **Slow queries**: Prisma's queries are taking too long to execute, preventing it from releasing connections for reuse. #### Solutions: [#solution-timed-out-fetching-a-new-connection] @@ -124,7 +124,7 @@ Postgres or Supavisor rejected a request for more connections #### Solutions [#solutions-causes-max-client-connections-reached] - **Transaction Mode for serverless apps**: If you are using serverless functions (Supabase Edge, Vercel, AWS Lambda), switch to transaction mode (port 6543). It handles more connections than session mode or direct connections. -- **Reduce the number of Prisma connections**: A single client-server can establish multiple connections with a pooler. Typically, serverless setups do not need many connections. Starting with fewer, like five or three, or even just one, is often sufficient. In serverless setups, begin with `connection_limit=1`, increasing cautiously if needed to avoid maxing out connections. +- **Reduce the number of Prisma connections**: A single client-server can establish multiple connections with a pooler. Typically, serverless setups do not need many connections. Starting with fewer, like five or three, or even one, is often sufficient. In serverless setups, begin with `connection_limit=1`, increasing cautiously if needed to avoid maxing out connections. - **Increase pool size**: If you are connecting with Supavisor, try increasing the pool size in the [Database Settings](/dashboard/project/_/database/settings). - **Disconnect appropriately**: Close Prisma connections when they are no longer needed. - **Decrease query time**: Reduce query complexity or add [strategic indexes](/docs/guides/database/postgres/indexes) to your tables to speed up queries. diff --git a/apps/docs/content/guides/database/replication.mdx b/apps/docs/content/guides/database/replication.mdx index 8ff0b26278f..7ad4408b84e 100644 --- a/apps/docs/content/guides/database/replication.mdx +++ b/apps/docs/content/guides/database/replication.mdx @@ -1,7 +1,7 @@ --- id: 'replication' title: 'Database replication' -description: 'Compare read replicas, external replication (ETL), and manual replication.' +description: 'Compare read replicas, Supabase Pipelines, and manual replication.' subtitle: 'An introduction to database replication and change data capture.' sidebar_label: 'Overview' --- @@ -18,7 +18,7 @@ You might use database replication for: ## Replication methods -Supabase supports three replication methods. Choose based on whether you need another Supabase Postgres database, a managed pipeline to a destination system, or full control over your own logical replication setup. +Supabase supports three replication methods. Choose based on whether you need another Supabase Postgres database, a managed replication pipeline to a destination system, or full control over your own logical replication setup. ### Read replicas @@ -26,21 +26,23 @@ Read replicas are additional Supabase Postgres databases kept in sync with your - [Set up read replicas](/docs/guides/platform/read-replicas) -### External replication (ETL) [#external-replication] +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + +### Pipelines -External replication (ETL) is currently in private alpha. Managed pipelines run through Supabase ETL. Access is limited and features may change. +Supabase Pipelines is currently in private alpha. Private alpha features can be unstable and may introduce breaking changes while we evaluate the product direction, refine the feature set, and incorporate customer feedback. -External replication (ETL) is powered by [Supabase ETL](https://github.com/supabase/etl). It uses Postgres logical replication under the hood and provides a managed Dashboard workflow for replicating data from Supabase Postgres to destination systems. In this context, "external" means outside the source database. Destinations can be Supabase-managed or third-party systems as support expands. +Pipelines is a managed CDC feature for creating replication pipelines from Supabase Postgres to destination systems. It uses Postgres logical replication under the hood with the open-source [Supabase ETL engine](https://github.com/supabase/etl). A destination is where your replicated data is stored; the pipeline is the managed process that continuously streams database changes to that destination. -- [Set up external replication (ETL)](/docs/guides/database/replication/external-replication-setup) +- [Set up Pipelines](/docs/guides/database/replication/pipelines) #### Supported destinations -External replication (ETL) currently supports BigQuery as the managed destination. We are working on new destinations, and this table will be updated as support expands. +Pipelines currently supports BigQuery as the managed destination. We are working on new destinations, and this table will be updated as support expands. | Destination | Insert | Update | Delete | Truncate | Schema change | Description | | ------------------------------------------------------ | ------------ | ------------ | ------------ | ------------ | ------------- | ------------------------------------------------------------------- | @@ -48,7 +50,7 @@ External replication (ETL) currently supports BigQuery as the managed destinatio ### Manual replication -Manual replication uses the same underlying Postgres logical replication features as external replication (ETL), but you configure and operate the pieces yourself. Use this path when you want to connect tools such as Airbyte, Estuary, Fivetran, Materialize, Stitch, AWS DMS, or another system that supports Postgres logical replication. +Manual replication uses the same underlying Postgres logical replication features as Pipelines, but you configure and operate the pieces yourself. Use this path when you want to connect tools such as Airbyte, Estuary, Fivetran, Materialize, Stitch, AWS DMS, or another system that supports Postgres logical replication. - [Set up manual replication](/docs/guides/database/replication/manual-replication-setup) @@ -78,7 +80,7 @@ When setting up logical replication, three key components are involved: - `publication` - A set of tables on your primary database that will be `published` - `replication slot` - A slot used for replicating the data from a single publication. The slot, when created, will specify the output format of the changes -- `subscription` - A subscription is created from an external system (i.e. another Postgres database) and must specify the name of the `publication`. If you do not specify a replication slot, one is automatically created +- `subscription` - A subscription is created from an external system (that is, another Postgres database) and must specify the name of the `publication`. If you do not specify a replication slot, one is automatically created ## Logical replication output format @@ -86,7 +88,7 @@ Logical replication is typically output in two forms, `pgoutput` and `wal2json`. ## Logical replication configuration -When using logical replication, Postgres keeps WAL files around for longer than it otherwise needs them. If the files are removed too quickly, then your `replication slot` can become inactive or lost if the database receives a large number of changes in a short time. +When using logical replication, Postgres keeps WAL files around for longer than it otherwise needs them. If the files are removed too soon, then your `replication slot` can become inactive or lost if the database receives a large number of changes in a short time. In order to mitigate this, Postgres has many options and settings that can be [tweaked](/docs/guides/database/custom-postgres-config) to manage the WAL usage effectively. Not all of these settings are user configurable as they can impact the stability of your database. For those that are, these should be considered as advanced configuration and not changed without understanding that they can cause additional disk space and resources to be used, as well as incur additional costs. diff --git a/apps/docs/content/guides/database/replication/bigquery.mdx b/apps/docs/content/guides/database/replication/bigquery.mdx index 12f1ae14fc9..ca96330f844 100644 --- a/apps/docs/content/guides/database/replication/bigquery.mdx +++ b/apps/docs/content/guides/database/replication/bigquery.mdx @@ -1,14 +1,14 @@ --- id: 'bigquery-destination' title: 'BigQuery destination' -description: 'Configure BigQuery as an external replication (ETL) destination.' +description: 'Configure BigQuery as a Supabase Pipelines destination.' subtitle: 'Replicate Supabase Postgres tables to BigQuery.' sidebar_label: 'BigQuery' --- -External replication (ETL) is currently in private alpha. Managed pipelines run through Supabase ETL. Access is limited and features may change. +Supabase Pipelines is currently in private alpha. Private alpha features can be unstable and may introduce breaking changes while we evaluate the product direction, refine the feature set, and incorporate customer feedback. @@ -75,7 +75,7 @@ Required permissions: | **Connection pool size** | `4` connections | Size of the BigQuery Storage Write API connection pool. More connections allow more parallel writes, but consume more resources. | | **Maximum staleness** | No staleness limit | Maximum allowed age, in minutes, for BigQuery cached metadata before reading base tables. Lower values improve freshness. Higher values can reduce query cost and latency. | -6. Click **Create and start** to begin replication +6. Click **Create and start pipeline** to begin replication Your replication pipeline now starts copying data from your database to BigQuery. @@ -90,7 +90,7 @@ Once configured, replication to BigQuery: ## Source table requirements -BigQuery replication requires each source table to have a primary key, and the publication must include the primary-key columns. Supabase ETL declares those columns as the BigQuery destination primary key so BigQuery change data capture (CDC) can apply `UPSERT` and `DELETE` rows. +BigQuery replication requires each source table to have a primary key, and the publication must include the primary-key columns. Pipelines declares those columns as the BigQuery destination primary key so BigQuery change data capture (CDC) can apply `UPSERT` and `DELETE` rows. BigQuery primary keys are `NOT ENFORCED`, and BigQuery change data capture (CDC) supports composite primary keys with up to 16 columns. Your source primary key must stay unique and non-null because BigQuery uses it to match CDC rows. @@ -104,7 +104,7 @@ Source tables must also use a BigQuery-compatible Postgres `REPLICA IDENTITY` se | `REPLICA IDENTITY NOTHING` | Not supported | Updates and deletes do not include enough row identity for BigQuery to apply them safely. | | `REPLICA IDENTITY DEFAULT` without a primary key | Not supported | BigQuery requires a source primary key. | -For a general explanation of how replica identity affects update and delete events, see [How does replica identity affect updates and deletes?](/docs/guides/database/replication/external-replication-faq#how-does-replica-identity-affect-updates-and-deletes). +For a general explanation of how replica identity affects update and delete events, see [How does replica identity affect updates and deletes?](/docs/guides/database/replication/pipelines-faq#how-does-replica-identity-affect-updates-and-deletes). For updates, Postgres does not always send a complete old row through logical replication. It can also mark unchanged toasted values as `unchanged toast` instead of resending the value. BigQuery change data capture (CDC) upserts require a complete new row because omitted columns are not preserved in the destination. The replication pipeline can reconstruct a complete update when the old row image contains the missing value, which is reliable with `REPLICA IDENTITY FULL`. @@ -145,7 +145,7 @@ This structure handles table truncations while maintaining query compatibility. ## Schema change support -Schema change support for BigQuery is currently in beta. Supabase ETL supports a limited set of schema changes for BigQuery while the feature is developed further. +Schema change support for BigQuery is currently in beta. Pipelines supports a limited set of schema changes for BigQuery while the feature is developed further. Supported schema changes: @@ -162,7 +162,7 @@ Unsupported or limited schema changes: - Filling existing rows for `ADD COLUMN ... DEFAULT` - Unsupported default expressions -BigQuery requires added columns to be nullable. When a replicated `ADD COLUMN` includes a default, external replication (ETL) can apply supported default metadata for future rows, but BigQuery does not backfill existing rows through that DDL. Existing destination rows remain `NULL` unless you run a separate backfill. +BigQuery requires added columns to be nullable. When a replicated `ADD COLUMN` includes a default, Pipelines can apply supported default metadata for future rows, but BigQuery does not backfill existing rows through that DDL. Existing destination rows remain `NULL` unless you run a separate backfill. Supported defaults are best-effort translations to BigQuery SQL. Unsupported defaults are skipped with a warning instead of failing replication. diff --git a/apps/docs/content/guides/database/replication/manual-replication-setup.mdx b/apps/docs/content/guides/database/replication/manual-replication-setup.mdx index 8d5076fedc2..e2d414c8c62 100644 --- a/apps/docs/content/guides/database/replication/manual-replication-setup.mdx +++ b/apps/docs/content/guides/database/replication/manual-replication-setup.mdx @@ -6,7 +6,7 @@ subtitle: 'Set up replication with Airbyte, Estuary, Fivetran, and other tools.' sidebar_label: 'Setting up' --- -This guide covers setting up **manual logical replication** using your own tools. If you prefer a managed solution through Supabase ETL, read the [external replication (ETL) setup guide](/docs/guides/database/replication/external-replication-setup) instead. +This guide covers setting up **manual logical replication** using your own tools. If you prefer a managed solution, read [Set up Pipelines](/docs/guides/database/replication/pipelines) instead. diff --git a/apps/docs/content/guides/database/replication/external-replication-faq.mdx b/apps/docs/content/guides/database/replication/pipelines-faq.mdx similarity index 67% rename from apps/docs/content/guides/database/replication/external-replication-faq.mdx rename to apps/docs/content/guides/database/replication/pipelines-faq.mdx index 6c56a465e64..2b1ee6b92e8 100644 --- a/apps/docs/content/guides/database/replication/external-replication-faq.mdx +++ b/apps/docs/content/guides/database/replication/pipelines-faq.mdx @@ -1,26 +1,28 @@ --- -id: 'external-replication-faq' -title: 'External replication (ETL) FAQ' -description: 'Frequently asked questions about external replication (ETL).' -subtitle: 'Common questions and answers about external replication (ETL).' +id: 'pipelines-faq' +title: 'Pipelines FAQ' +description: 'Frequently asked questions about Supabase Pipelines.' +subtitle: 'Common questions and answers about managed replication pipelines.' sidebar_label: 'FAQ' --- -External replication (ETL) is currently in private alpha. Managed pipelines run through Supabase ETL. Access is limited and features may change. +Supabase Pipelines is currently in private alpha. Private alpha features can be unstable and may introduce breaking changes while we evaluate the product direction, refine the feature set, and incorporate customer feedback. ## What destinations are supported? -External replication (ETL) currently supports **BigQuery** as the managed destination. See the [BigQuery destination guide](/docs/guides/database/replication/bigquery) for configuration details. +Pipelines currently supports **BigQuery** as the managed destination. See the [BigQuery destination guide](/docs/guides/database/replication/bigquery) for configuration details. -We are working on new destinations. "External" means outside the source database, so supported destinations can be Supabase-managed or third-party systems as support expands. Availability may continue to vary based on the planned roll-out strategy. +We are working on new destinations. Supported destinations can be Supabase-managed or third-party systems as support expands. Availability may continue to vary based on the planned roll-out strategy. -## Which plans support external replication (ETL)? [#which-plans-support-external-replication] +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} -External replication (ETL) is available on the Pro, Team, and Enterprise plans. +## Which plans support Pipelines? + +Pipelines is available on the Pro, Team, and Enterprise plans. {/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} @@ -28,13 +30,15 @@ External replication (ETL) is available on the Pro, Team, and Enterprise plans. We are currently working on a new Supabase Warehouse product designed to address the limitations of the previous Analytics Buckets. Our goal is to build a solution we can confidently stand behind, rather than continuing to support an approach that does not meet the quality and flexibility we want for our users. -As a result, managed replication to Analytics Buckets is no longer available. Right now, **BigQuery** is the only supported managed destination, and we are actively working on expanding capabilities. +As a result, replication into Analytics Buckets via Pipelines is no longer supported. Right now, **BigQuery** is the only supported managed destination, and we are actively working on expanding capabilities. -## What does external replication (ETL) install in the database? [#what-does-external-replication-install-in-the-database] +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} -When you enable external replication (ETL), Supabase installs database objects that help Supabase ETL track replication state and support schema changes: +## What does Pipelines install in the database? -- An event trigger that runs on every `ALTER TABLE` statement. Supabase ETL uses this to support schema change handling. +When you enable Pipelines, Supabase installs database objects that help track replication state and support schema changes: + +- An event trigger that runs on every `ALTER TABLE` statement. Supabase uses this to support schema change handling. - A set of tables in the `etl` schema. These tables track replication state for your pipelines. The replication state tables are not updated very often, especially after the initial copy phase is complete. @@ -51,15 +55,17 @@ Supported schema changes: - Dropping a `NOT NULL` constraint - Setting or dropping supported column default metadata -External replication (ETL) does not currently support changing column data types, adding `NOT NULL` with `SET NOT NULL`, or filling existing destination rows for `ADD COLUMN ... DEFAULT`. Supported defaults are applied as destination metadata for future rows where BigQuery can represent them. See [BigQuery schema change support](/docs/guides/database/replication/bigquery#schema-change-support) for details. +Pipelines does not currently support changing column data types, adding `NOT NULL` with `SET NOT NULL`, or filling existing destination rows for `ADD COLUMN ... DEFAULT`. Supported defaults are applied as destination metadata for future rows where BigQuery can represent them. See [BigQuery schema change support](/docs/guides/database/replication/bigquery#schema-change-support) for details. -## What happens when you disable external replication (ETL)? [#what-happens-when-you-disable-external-replication] +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} -Disabling external replication (ETL) removes the database objects that were installed in your source database, including the replication state tables in the `etl` schema and the DDL event trigger. +## What happens when you disable Pipelines? -To remove external replication (ETL) from a project, delete all replication pipelines first. After all pipelines are deleted, the disable action becomes available. For the Dashboard steps, see [Disabling external replication (ETL)](/docs/guides/database/replication/external-replication-setup#disabling-external-replication). +Disabling Pipelines removes the database objects that were installed in your source database, including the replication state tables in the `etl` schema and the DDL event trigger. -Disabling external replication (ETL) stops Supabase from managing replication for the project. It does not delete tables or data that were already written to your destination. +To remove Pipelines from a project, delete all replication pipelines first. After all pipelines are deleted, the disable action becomes available. For the Dashboard steps, see [Disabling Pipelines](/docs/guides/database/replication/pipelines#disabling-pipelines). + +Disabling Pipelines stops Supabase from managing replication for the project. It does not delete tables or data that were already written to your destination. ## Why is a table not being replicated? @@ -75,11 +81,11 @@ Custom data types replicate as strings. Check that your destination can interpre ## Why are partitioned tables replicated as separate tables? -Postgres controls this with the publication's `publish_via_partition_root` setting. If the setting is `false`, or if you created the publication manually with SQL and did not set it, Postgres publishes changes from the leaf partitions. External replication (ETL) then creates destination tables for those leaf partitions. If `publish_via_partition_root = true`, Postgres publishes changes as the partition root, so the partition hierarchy is treated as the published partition root. +Postgres controls this with the publication's `publish_via_partition_root` setting. If the setting is `false`, or if you created the publication manually with SQL and did not set it, Postgres publishes changes from the leaf partitions. Pipelines then creates destination tables for those leaf partitions. If `publish_via_partition_root = true`, Postgres publishes changes as the partition root, so the partition hierarchy is treated as the published partition root. Publications created from the Dashboard replication flow use `publish_via_partition_root = true`. -See [Partitioned tables](/docs/guides/database/replication/external-replication-setup#partitioned-tables) for examples and the full behavior. +See [Partitioned tables](/docs/guides/database/replication/pipelines#partitioned-tables) for examples and the full behavior. ## How does replica identity affect updates and deletes? @@ -131,24 +137,38 @@ After changing replica identity, restart each pipeline that includes the affecte ## Why aren't publication changes reflected after adding or removing tables? -After modifying your Postgres publication, you must restart the replication pipeline for changes to take effect. See [Adding or removing tables](/docs/guides/database/replication/external-replication-setup#adding-or-removing-tables) for instructions. +After modifying your Postgres publication, you must restart the replication pipeline for changes to take effect. See [Adding or removing tables](/docs/guides/database/replication/pipelines#adding-or-removing-tables) for instructions. ## Why is a pipeline in failed state? Pipeline failures occur during the streaming phase when an error happens while replicating live data. This prevents data loss. To recover: 1. Check the error message by hovering over the **Failed** status -2. Click **View status** for detailed information +2. Click **View pipeline** for detailed information 3. Fix the underlying issue (e.g., schema mismatches, destination connectivity) 4. Restart the pipeline -See [Handling errors](/docs/guides/database/replication/external-replication-monitoring#handling-errors) for more details. +See [Handling errors](/docs/guides/database/replication/pipelines-monitoring#handling-errors) for more details. + +## Why was the pipeline stopped? + +Supabase may automatically stop a pipeline if it encounters too many errors in a short period of time. This protects your source database, your destination, and the Pipelines system from a pipeline repeatedly retrying a failing operation. + +Automatic stopping is a safety mechanism. It prevents repeated pipeline errors from consuming resources or causing wider instability while preserving the pipeline's replication position so you can investigate the underlying issue. + +To recover: + +1. Check the pipeline error details and replication logs +2. Fix the underlying issue, such as destination connectivity, schema mismatches, permissions, or rate limits +3. Restart the pipeline manually from the [**Database > Replication**](/dashboard/project/_/database/replication) section of the Dashboard + +If the same error continues after restart, the pipeline may stop again. Review [Monitor pipeline status](/docs/guides/database/replication/pipelines-monitoring) for troubleshooting steps. ## Why is replication lag increasing? Lag increases when Postgres produces WAL faster than the pipeline can confirm it has processed. Common causes include a slow or rate-limited destination, a pipeline issue, heavy source database activity, long transactions, network latency between the pipeline and source database, or a stopped/disconnected pipeline. -Open [**Database > Replication**](/dashboard/project/_/database/replication), click **View status**, and check **Waiting to sync**, **Room before pausing**, **Last check-in**, **Connected**, and **Slot status**. See [Dealing with replication lag](/docs/guides/database/replication/external-replication-monitoring#dealing-with-replication-lag) for the full investigation and response flow. +Open [**Database > Replication**](/dashboard/project/_/database/replication), click **View pipeline**, and check **Waiting to sync**, **Room before pausing**, **Last check-in**, **Connected**, and **Slot status**. See [Dealing with replication lag](/docs/guides/database/replication/pipelines-monitoring#dealing-with-replication-lag) for the full investigation and response flow. ## What does a `Lost` slot status mean? @@ -156,26 +176,26 @@ Open [**Database > Replication**](/dashboard/project/_/database/replication), cl You can recreate the pipeline, or open the pipeline's **Advanced settings**, set **Invalidated slot behavior** to **Recreate**, and restart the pipeline. On restart, the pipeline creates a new replication slot and starts replication from scratch for all tables. This is required for consistency because the old slot can no longer provide every change the pipeline missed. -See [Slot statuses](/docs/guides/database/replication/external-replication-monitoring#slot-statuses) for all slot states and what to do next. +See [Slot statuses](/docs/guides/database/replication/pipelines-monitoring#slot-statuses) for all slot states and what to do next. ## Why is a table in error state? -Table errors occur during the copy phase. To recover, click **View status**, find the affected table, and reset the table state. This will restart the table copy from the beginning. +Table errors occur during the copy phase. To recover, click **View pipeline**, find the affected table, and reset the table state. This will restart the table copy from the beginning. ## How to verify replication is working Check the [**Database > Replication**](/dashboard/project/_/database/replication) section of the Dashboard: 1. Verify your pipeline shows **Running** status -2. Click **View status** to check table states +2. Click **View pipeline** to check table states 3. Ensure all tables show **Live** state (actively replicating) 4. Monitor replication lag metrics -See the [external replication (ETL) monitoring guide](/docs/guides/database/replication/external-replication-monitoring) for comprehensive monitoring instructions. +See [Monitor pipeline status](/docs/guides/database/replication/pipelines-monitoring) for comprehensive monitoring instructions. ## How to stop or pause replication -You can manage your pipeline using the actions menu in the destinations list. See [Managing your pipeline](/docs/guides/database/replication/external-replication-setup#managing-your-pipeline) for details on available actions. +You can manage your pipeline using the actions menu in the destinations list. See [Managing your pipeline](/docs/guides/database/replication/pipelines#managing-your-pipeline) for details on available actions. @@ -185,13 +205,13 @@ Stopping replication causes changes to queue up in the WAL. ## What happens if a project becomes inactive? -If your project becomes inactive, external replication (ETL) stops any running pipelines and does not automatically resume them after the project is restarted. +If your project becomes inactive, Pipelines stops any running pipelines and does not automatically resume them after the project is restarted. After restarting the project, restart each replication pipeline manually from the [**Database > Replication**](/dashboard/project/_/database/replication) section of the Dashboard. ## What happens after a downgrade to the free plan? -When a project is downgraded to the Free Plan, all external replication (ETL) pipelines for that project are deleted. +When a project is downgraded to the Free Plan, all replication pipelines created with Pipelines for that project are deleted. ## What happens if a table is deleted at the destination? @@ -245,6 +265,6 @@ Navigate to the [**Logs > Replication**](/dashboard/project/_/logs/explorer) sec If you need assistance: -1. Check the [external replication (ETL) setup guide](/docs/guides/database/replication/external-replication-setup) and [external replication (ETL) monitoring guide](/docs/guides/database/replication/external-replication-monitoring) +1. Check [Set up Pipelines](/docs/guides/database/replication/pipelines) and [Monitor pipeline status](/docs/guides/database/replication/pipelines-monitoring) 2. Review this FAQ for common issues 3. Contact support with your error details and logs diff --git a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx b/apps/docs/content/guides/database/replication/pipelines-monitoring.mdx similarity index 89% rename from apps/docs/content/guides/database/replication/external-replication-monitoring.mdx rename to apps/docs/content/guides/database/replication/pipelines-monitoring.mdx index 789607ce586..f466c8108bd 100644 --- a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx +++ b/apps/docs/content/guides/database/replication/pipelines-monitoring.mdx @@ -1,18 +1,18 @@ --- -id: 'external-replication-monitoring' -title: 'External replication (ETL) monitoring' -description: 'Monitor the status and health of your external replication (ETL) pipelines.' +id: 'pipelines-monitoring' +title: 'Monitor pipeline status' +description: 'Monitor the status and health of pipelines created with Supabase Pipelines.' subtitle: 'Track replication status, view logs, and troubleshoot issues.' sidebar_label: 'Monitoring' --- -External replication (ETL) is currently in private alpha. Managed pipelines run through Supabase ETL. Access is limited and features may change. +Supabase Pipelines is currently in private alpha. Private alpha features can be unstable and may introduce breaking changes while we evaluate the product direction, refine the feature set, and incorporate customer feedback. -After setting up external replication (ETL), you can monitor the status and health of your replication pipelines directly from the Dashboard. The pipeline is the active Postgres replication process that continuously streams changes from your database to your destination. +After setting up Pipelines, you can monitor the status and health of your replication pipelines directly from the Dashboard. The pipeline is the active Postgres replication process that continuously streams changes from your database to your destination. ### Viewing pipeline status @@ -43,10 +43,10 @@ Each destination shows its pipeline in one of these states: ### Viewing detailed pipeline metrics -For detailed information about a specific pipeline, click **View status** on the destination. This opens the pipeline status page where you can monitor replication performance and table states. +For detailed information about a specific pipeline, click **View pipeline** on the destination. This opens the pipeline status page where you can monitor replication performance and table states. Pipeline Status View Replication**](/dashboard/project/_/database/replication) and check the destination's lag column. -2. Click **View status** and check **Waiting to sync**, **Room before pausing**, **Last check-in**, **Connected**, and **Slot status**. +2. Click **View pipeline** and check **Waiting to sync**, **Room before pausing**, **Last check-in**, **Connected**, and **Slot status**. 3. Check table states. Tables in **Copying** can create temporary lag while the initial snapshot catches up to live changes. If a table-sync slot is **Unreserved** or **Lost**, tune copy parallelism and retry the affected table copy. 4. Open [**Logs > Replication**](/dashboard/project/_/logs/explorer) and look for destination errors, retries, rate limits, schema errors, or repeated restarts. 5. Compare the lag trend with recent database activity, such as imports, migrations, bulk updates, or long transactions. @@ -136,7 +136,7 @@ After the affected table finishes copying and catches up, the temporary slot is | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Reserved** | If **Waiting to sync** is stable or decreasing, continue monitoring. If it keeps increasing, check destination write performance, logs, and whether the publication includes more tables or write volume than expected. | | **Extended** | Treat it as an early warning. Confirm the pipeline is connected, check logs for retries or destination slowness, and reduce avoidable write bursts if possible until the pipeline catches up. | -| **Unreserved** | Act quickly. The slot is at risk of losing required WAL. Check whether the pipeline is connected and making progress, fix destination or pipeline errors, and contact support if the lag continues to grow. | +| **Unreserved** | Act soon. The slot is at risk of losing required WAL. Check whether the pipeline is connected and making progress, fix destination or pipeline errors, and contact support if the lag continues to grow. | | **Lost** | The pipeline cannot continue from the existing slot because required WAL has been removed. Recreate the pipeline, or set **Invalidated slot behavior** to **Recreate** in the pipeline's advanced settings and restart the pipeline. This creates a new slot and starts replication from scratch for all tables. | | **Unknown** | Check replication logs for errors or missing slot details. If the status remains unknown while the pipeline should be running, contact support with the pipeline ID and recent log details. | @@ -166,7 +166,7 @@ height={2146} **Viewing table error details:** -1. Click **View status** on your destination +1. Click **View pipeline** on your destination 2. Check the table states section to identify tables in **Error** state 3. Review the error message for that specific table @@ -185,7 +185,7 @@ When a pipeline error occurs, you'll receive an email notification immediately. Pipeline Error Details Replication**](/dashboard/project/_/logs/explorer) section of the Dashboard for detailed error logs **Recovering from pipeline errors:** @@ -233,7 +233,7 @@ Logs contain diagnostic information that may be too technical for most users. If 1. Navigate to the [**Database > Replication**](/dashboard/project/_/database/replication) section of the Dashboard 2. Verify your destination shows a "Running" status -3. Click **View status** to check replication lag and table states +3. Click **View pipeline** to check replication lag and table states 4. Ensure all tables show a "Live" state #### Investigating errors @@ -241,7 +241,7 @@ Logs contain diagnostic information that may be too technical for most users. If If you see a **Failed** status: 1. Hover over the status to see the error summary -2. Click **View status** to see detailed error information +2. Click **View pipeline** to see detailed error information 3. Check table states to identify which tables are affected 4. Navigate to the [**Logs > Replication**](/dashboard/project/_/logs/explorer) section of the Dashboard for full error details 5. For table errors, attempt to reset the affected tables @@ -265,9 +265,9 @@ If you notice issues with your replication: 4. **Verify publication**: Ensure your Postgres publication is properly configured 5. **Monitor replication lag**: High lag may indicate performance issues -For more troubleshooting tips, see the [external replication (ETL) FAQ](/docs/guides/database/replication/external-replication-faq). +For more troubleshooting tips, see the [Pipelines FAQ](/docs/guides/database/replication/pipelines-faq). ### Next steps -- [Set up external replication (ETL)](/docs/guides/database/replication/external-replication-setup) -- [View external replication (ETL) FAQ](/docs/guides/database/replication/external-replication-faq) +- [Set up Pipelines](/docs/guides/database/replication/pipelines) +- [View the Pipelines FAQ](/docs/guides/database/replication/pipelines-faq) diff --git a/apps/docs/content/guides/database/replication/external-replication-setup.mdx b/apps/docs/content/guides/database/replication/pipelines.mdx similarity index 74% rename from apps/docs/content/guides/database/replication/external-replication-setup.mdx rename to apps/docs/content/guides/database/replication/pipelines.mdx index 849560ba439..f295c17436c 100644 --- a/apps/docs/content/guides/database/replication/external-replication-setup.mdx +++ b/apps/docs/content/guides/database/replication/pipelines.mdx @@ -1,26 +1,26 @@ --- -id: 'external-replication-setup' -title: 'Set up external replication (ETL)' -description: 'Set up external replication (ETL) using Postgres logical replication.' -subtitle: 'Configure publications and destinations for external replication (ETL).' +id: 'pipelines' +title: 'Set up Pipelines' +description: 'Create a managed replication pipeline using Postgres logical replication.' +subtitle: 'Configure publications, destinations, and managed replication pipelines.' sidebar_label: 'Setting up' --- -External replication (ETL) is currently in private alpha. Managed pipelines run through Supabase ETL. Access is limited and features may change. +Supabase Pipelines is currently in private alpha. Private alpha features can be unstable and may introduce breaking changes while we evaluate the product direction, refine the feature set, and incorporate customer feedback. -External replication (ETL) is powered by [Supabase ETL](https://github.com/supabase/etl) and uses **Postgres logical replication** to stream changes from your database to destination systems. It provides a managed interface through the Dashboard to configure and monitor replication pipelines. In this context, "external" means outside the source database; destinations can be Supabase-managed or third-party systems as support expands. +Pipelines is a managed CDC feature for creating replication pipelines from Supabase Postgres to destination systems. It uses **Postgres logical replication** with the open-source [Supabase ETL engine](https://github.com/supabase/etl). You choose a destination in the Dashboard, and Supabase runs the pipeline that streams database changes to that destination. ## Setup overview -External replication (ETL) requires two main components: a **Postgres publication** (defines what to replicate) and a **destination** (where data is sent). Supabase ETL runs the managed pipeline that reads from the publication and writes to the destination. Follow these steps to set up your replication pipeline. +Pipelines requires two main components: a **Postgres publication** (defines what to replicate) and a **destination** (where data is sent). Supabase runs the managed pipeline that reads from the publication and writes to the destination. Follow these steps to set up your replication pipeline. -If you already have a Postgres publication set up, you can skip to [Step 2: Enable external replication (ETL)](#step-2-enable-external-replication). +If you already have a Postgres publication set up, you can skip to [Step 2: Enable Pipelines](#step-2-enable-pipelines). @@ -90,7 +90,7 @@ for table orders where (created_at > '2024-01-01'); ##### Partitioned tables -External replication (ETL) follows Postgres publication semantics for partitioned tables. The `publish_via_partition_root` publication setting controls whether changes from partitions are emitted as the partition root or as the leaf partitions. +Pipelines follows Postgres publication semantics for partitioned tables. The `publish_via_partition_root` publication setting controls whether changes from partitions are emitted as the partition root or as the leaf partitions. | Publication setting | What gets replicated | Destination shape | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | @@ -118,7 +118,7 @@ Use `publish_via_partition_root = true` when you want analytics queries to read Publications created from the Dashboard replication flow use `publish_via_partition_root = true`. If you create or alter a publication manually with SQL, set this option explicitly so the destination shape matches what you expect. -On Postgres 15 and newer, row filters on partition publications apply during both the initial copy and streaming phases. External replication (ETL) uses the row filter attached to the effective publication table entry: the published partition root when `publish_via_partition_root = true`, and the published leaf relation when `publish_via_partition_root = false`. +On Postgres 15 and newer, row filters on partition publications apply during both the initial copy and streaming phases. Pipelines uses the row filter attached to the effective publication table entry: the published partition root when `publish_via_partition_root = true`, and the published leaf relation when `publish_via_partition_root = false`. @@ -133,17 +133,19 @@ After creating a publication via SQL, you can view it in the Dashboard: 1. Navigate to the [**Database > Publications**](/dashboard/project/_/database/publications) section of the Dashboard 2. You'll see all your publications listed with their tables -### Step 2: Enable external replication (ETL) [#step-2-enable-external-replication] +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} -Before creating an external replication (ETL) pipeline, enable it for your project: +### Step 2: Enable Pipelines + +Before creating a managed replication pipeline, enable Pipelines for your project: 1. Navigate to the [**Database > Replication**](/dashboard/project/_/database/replication) section of the Dashboard 2. Click **Add destination** to show the replication side panel -3. Select an external replication (ETL) destination, such as **BigQuery** -4. Click **Enable external replication (ETL)** +3. Select a Pipelines destination, such as **BigQuery** +4. Click **Enable Pipelines** Enable external replication (ETL) -For comprehensive monitoring instructions including pipeline states, metrics, and logs, see the [external replication (ETL) monitoring guide](/docs/guides/database/replication/external-replication-monitoring). +For comprehensive monitoring instructions including pipeline states, metrics, and logs, see [Monitor pipeline status](/docs/guides/database/replication/pipelines-monitoring). ### Managing your pipeline @@ -213,24 +215,26 @@ You can manage your pipeline from the destinations list using the actions menu. Available actions: -- **Start**: Begin replication for a stopped pipeline +- **Start pipeline**: Begin replication for a stopped pipeline - **Stop**: Pause replication (changes will queue up in the WAL) -- **Restart**: Stop and start the pipeline (required after publication changes) +- **Restart pipeline**: Stop and start the pipeline (required after publication changes) - **Edit destination**: Modify destination settings like credentials or advanced options - **Delete**: Remove the destination and permanently stop replication -### Disabling external replication (ETL) [#disabling-external-replication] +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} -To turn off external replication (ETL) for a project, delete all replication pipelines first. After all pipelines are removed, open the three-dot actions menu on the Replication page and click **Disable external replication (ETL)**. +### Disabling Pipelines + +To turn off Pipelines for a project, delete all replication pipelines first. After all pipelines are removed, open the three-dot actions menu on the Replication page and click **Disable Pipelines**. Disable external replication (ETL) from the Replication page actions menu -For cleanup details, see [What happens when you disable external replication (ETL)?](/docs/guides/database/replication/external-replication-faq#what-happens-when-you-disable-external-replication). +For cleanup details, see [What happens when you disable Pipelines?](/docs/guides/database/replication/pipelines-faq#what-happens-when-you-disable-pipelines). ### Adding or removing tables @@ -272,7 +276,7 @@ If your Postgres publication uses `FOR ALL TABLES` or `FOR TABLES IN SCHEMA`, ne -When a table is deleted at the destination, the behavior depends on the destination. In general, the pipeline tries to recreate the table so replication can continue. To permanently delete a table, pause the pipeline first or remove it from the publication before deleting. See the [external replication (ETL) FAQ](/docs/guides/database/replication/external-replication-faq#what-happens-if-a-table-is-deleted-at-the-destination) for details. +When a table is deleted at the destination, the behavior depends on the destination. In general, the pipeline tries to recreate the table so replication can continue. To permanently delete a table, pause the pipeline first or remove it from the publication before deleting. See the [Pipelines FAQ](/docs/guides/database/replication/pipelines-faq#what-happens-if-a-table-is-deleted-at-the-destination) for details. @@ -282,13 +286,13 @@ Schema change support depends on the destination. BigQuery is currently the only ### How it works -Once configured, external replication (ETL): +Once configured, a replication pipeline: 1. **Captures** changes from your Postgres database using Postgres publications and logical replication -2. **Streams** the changes through Supabase ETL +2. **Streams** the changes through the managed pipeline 3. **Loads** the data to your destination -External replication (ETL) automatically optimizes how changes are delivered to the destination. Supabase ETL currently performs data extraction and loading only, without transformation - your data is replicated as-is to the destination. +Pipelines automatically optimizes how changes are delivered to the destination. It currently performs data extraction and loading only, without transformation - your data is replicated as-is to the destination. ### Troubleshooting @@ -299,11 +303,11 @@ If you encounter issues during setup: - **Pipeline failed to start**: Check the error message in the status view for specific details - **No data being replicated**: Verify your Postgres publication includes the correct tables and event types -For more troubleshooting help, see the [external replication (ETL) FAQ](/docs/guides/database/replication/external-replication-faq). +For more troubleshooting help, see the [Pipelines FAQ](/docs/guides/database/replication/pipelines-faq). ### Limitations -External replication (ETL) has the following limitations: +Pipelines has the following limitations: - **Primary keys required**: Tables must have primary keys (Postgres logical replication requirement) - **Custom data types**: Custom values replicate as strings. Check that your destination can interpret those string values correctly. @@ -311,12 +315,12 @@ External replication (ETL) has the following limitations: - **Replica identity**: `REPLICA IDENTITY FULL` is strongly recommended. Updates and deletes need enough row data to apply changes correctly at the destination. - **Schema changes**: Currently in beta and limited to BigQuery - **No data transformation**: Data is replicated as-is without transformation -- **Data duplicates**: Duplicates can occur when stopping a pipeline if your database has transactions that take longer than a few minutes to complete. See [Can data duplicates occur during pipeline operations?](/docs/guides/database/replication/external-replication-faq#can-data-duplicates-occur-during-pipeline-operations) for details +- **Data duplicates**: Duplicates can occur when stopping a pipeline if your database has transactions that take longer than a few minutes to complete. See [Can data duplicates occur during pipeline operations?](/docs/guides/database/replication/pipelines-faq#can-data-duplicates-occur-during-pipeline-operations) for details Destination-specific limitations, such as BigQuery's row size limits, are documented in each destination guide. ### Next steps - [Set up BigQuery](/docs/guides/database/replication/bigquery) -- [Monitor external replication (ETL)](/docs/guides/database/replication/external-replication-monitoring) -- [View external replication (ETL) FAQ](/docs/guides/database/replication/external-replication-faq) +- [Monitor pipeline status](/docs/guides/database/replication/pipelines-monitoring) +- [View the Pipelines FAQ](/docs/guides/database/replication/pipelines-faq) diff --git a/apps/docs/content/guides/database/tables.mdx b/apps/docs/content/guides/database/tables.mdx index 2a210617936..f5c20bc080e 100644 --- a/apps/docs/content/guides/database/tables.mdx +++ b/apps/docs/content/guides/database/tables.mdx @@ -579,7 +579,7 @@ from where courses.code != 'PG101'; ``` -Without a view, we would need to go into every dependent query to add the new rule. This would increase in the likelihood of errors and inconsistencies, as well as introducing a lot of effort for a developer. With views, we can alter just the underlying query in the view **transcripts**. The change will be applied to all applications using this view. +Without a view, we would need to go into every dependent query to add the new rule. This would increase in the likelihood of errors and inconsistencies, as well as introducing a lot of effort for a developer. With views, we can alter the underlying query in the view **transcripts**. The change will be applied to all applications using this view. #### Logical organization diff --git a/apps/docs/content/guides/database/vault.mdx b/apps/docs/content/guides/database/vault.mdx index 2a6f0788973..8d703a5d509 100644 --- a/apps/docs/content/guides/database/vault.mdx +++ b/apps/docs/content/guides/database/vault.mdx @@ -127,7 +127,7 @@ You should ensure that you protect access to this view with the appropriate SQL ### Updating secrets -A secret can be updated with the `vault.update_secret()` function, this function makes updating secrets easy, just provide the secret UUID as the first argument, and then an updated secret, updated optional unique name, or updated description: +To update a secret, use the `vault.update_secret()` function. Provide the secret UUID as the first argument, followed by an updated secret, name, or description: ```sql select diff --git a/apps/docs/content/guides/database/webhooks.mdx b/apps/docs/content/guides/database/webhooks.mdx index 3e6a27af497..47496c0c868 100644 --- a/apps/docs/content/guides/database/webhooks.mdx +++ b/apps/docs/content/guides/database/webhooks.mdx @@ -12,7 +12,7 @@ You can hook into three table events: `INSERT`, `UPDATE`, and `DELETE`. All even ## Webhooks vs triggers -Database Webhooks are very similar to triggers, and that's because Database Webhooks are just a convenience wrapper around triggers using the [pg_net](/docs/guides/database/extensions/pgnet) extension. This extension is asynchronous, and therefore will not block your database changes for long-running network requests. +Database Webhooks are very similar to triggers, and that's because Database Webhooks are a convenience wrapper around triggers using the [pg_net](/docs/guides/database/extensions/pgnet) extension. This extension is asynchronous, and therefore will not block your database changes for long-running network requests. This video demonstrates how you can create a new customer in Stripe each time a row is inserted into a `profiles` table: @@ -32,7 +32,7 @@ This video demonstrates how you can create a new customer in Stripe each time a 1. Select the table you want to hook into. 1. Select one or more events (table inserts, updates, or deletes) you want to hook into. -Since webhooks are just database triggers, you can also create one from SQL statement directly. +Since webhooks are database triggers, you can also create one from SQL statement directly. ```sql create trigger "my_webhook" after insert diff --git a/apps/docs/content/guides/deployment/database-migrations.mdx b/apps/docs/content/guides/deployment/database-migrations.mdx index cc2333d0cbe..d50618060c2 100644 --- a/apps/docs/content/guides/deployment/database-migrations.mdx +++ b/apps/docs/content/guides/deployment/database-migrations.mdx @@ -475,13 +475,13 @@ This creates a new migration file capturing the current remote schema. Commit it ### Step 3: If the migration history table is wrong -If a migration shows as missing in the remote history table but the schema change is actually already there (for example, it was applied manually), you can mark it as applied without re-running it: +If a migration shows as missing in the remote history table but the schema change is already there (for example, it was applied manually), you can mark it as applied without re-running it: ```bash name=Terminal supabase migration repair --status applied ``` -Or if a migration is recorded as applied but was never actually run: +Or if a migration is recorded as applied but was never run: ```bash name=Terminal supabase migration repair --status reverted diff --git a/apps/docs/content/guides/deployment/going-into-prod.mdx b/apps/docs/content/guides/deployment/going-into-prod.mdx index ce8879923f2..ffa3b028896 100644 --- a/apps/docs/content/guides/deployment/going-into-prod.mdx +++ b/apps/docs/content/guides/deployment/going-into-prod.mdx @@ -22,7 +22,7 @@ Check and review issues in your database using [Security Advisor](/dashboard/pro - Tables that do not have RLS enabled with reasonable policies allow any client to access and modify their data. This is usually not what you want. - [Learn more about RLS](/docs/guides/database/postgres/row-level-security). - Enable replication on tables containing sensitive data by enabling RLS and setting row security policies: - - Go to the [**Authentication > Policies**](/dashboard/project/_/auth/policies) section of the Supabase Dashboard to enable RLS and create security policies. + - Go to the [**Database > Policies**](/dashboard/project/_/database/policies) section of the Supabase Dashboard to enable RLS and create security policies. - Go to the [**Database > Publications**](/dashboard/project/_/database/publications) section of the Supabase Dashboard to manage replication tables. - Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) from the [**Database > Settings > SSL Configuration**](/dashboard/project/_/database/settings#ssl-configuration) section of the dashboard. - Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for the database from the [**Database > Settings > Network Restrictions**](/dashboard/project/_/database/settings#network-restrictions) section of the dashboard. diff --git a/apps/docs/content/guides/deployment/shared-responsibility-model.mdx b/apps/docs/content/guides/deployment/shared-responsibility-model.mdx index e6d82cc9638..5cfed3746e3 100644 --- a/apps/docs/content/guides/deployment/shared-responsibility-model.mdx +++ b/apps/docs/content/guides/deployment/shared-responsibility-model.mdx @@ -99,6 +99,9 @@ You can use Supabase to store and process Protected Health Information (PHI). Yo - Turning on [SSL Enforcement](/docs/guides/platform/ssl-enforcement). - Enabling [Network Restrictions](/docs/guides/platform/network-restrictions). - Keeping [Postgres connection logging](/docs/guides/platform/postgres-connection-logging) enabled. Supabase sets `log_connections` to off by default for new projects. Projects that need HIPAA compliance should keep connection logging on for audit trails, and the Security Advisor warns if it is disabled. + +<$Partial path="log_connections_default_effective_date.mdx" /> + - Complying with encryption requirements in the HIPAA Security Rule. Data is encrypted at rest and in transit by Supabase. You can consider encrypting the data at your application layer. - Not storing PHI in [public Storage buckets](/docs/guides/storage/buckets/fundamentals#public-buckets). - Not [transferring projects](/docs/guides/platform/project-transfer) to a non-HIPAA organization. diff --git a/apps/docs/content/guides/functions.mdx b/apps/docs/content/guides/functions.mdx index 87d1b7f42e4..2982243fa10 100644 --- a/apps/docs/content/guides/functions.mdx +++ b/apps/docs/content/guides/functions.mdx @@ -1,8 +1,8 @@ --- id: 'functions' title: 'Edge Functions' -description: 'Globally distributed TypeScript functions.' -subtitle: 'Globally distributed TypeScript functions.' +description: 'Run TypeScript functions globally at the edge.' +subtitle: 'Run TypeScript functions globally at the edge.' sidebar_label: 'Overview' tocVideo: 'za_loEtS4gs' --- @@ -41,295 +41,18 @@ Edge Functions are server-side TypeScript functions, distributed globally at the - Sending transactional emails. - Building messaging bots for Slack, Discord, etc. -
- -
+ ## Examples -Check out the [Edge Function Examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) in our GitHub repository. +Check out [Supabase Edge Function Examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) in GitHub or try these examples: -
-
- - - Use the Supabase client inside your Edge Function. - - -
-
- - - Combining Kysely with Deno Postgres gives you a convenient developer experience for - interacting directly with your Postgres database. - - -
-
- - - Monitor Edge Functions with the Sentry Deno SDK. - - -
-
- - - Send CORS headers for invoking from the browser. - - -
-
- - - Full example for using Supabase and Stripe, with Expo. - - -
-
- - - Full example for using Supabase and Stripe, with Flutter. - - -
-
- - - Learn how to use HTTP methods and paths to build a RESTful service for managing tasks. - - -
-
- - - An example on reading a file from Supabase Storage. - - -
-
- - - Generate Open Graph images with Deno and Supabase Edge Functions. - - -
-
- - - Cache generated images with Supabase Storage CDN. - - -
-
- - - Get user location data from user's IP address. - - -
-
- - - Protecting Forms with Cloudflare Turnstile. - - -
-
- - - Connecting to Postgres from Edge Functions. - - -
-
- - - Deploying Edge Functions with GitHub Actions. - - -
-
- - - Request Routing with Oak server middleware. - - -
-
- - - Access 100,000+ Machine Learning models. - - -
-
- - - Amazon Bedrock Image Generator - - -
-
- - - Using OpenAI in Edge Functions. - - -
-
- - - Handling signed Stripe Webhooks with Edge Functions. - - -
-
- - - Send emails in Edge Functions with Resend. - - -
-
- - - Server-Sent Events in Edge Functions. - - -
-
- - - Generate screenshots with Puppeteer. - - -
-
- - - Building a Slash Command Discord Bot with Edge Functions. - - -
-
- - - Building a Telegram Bot with Edge Functions. - - -
-
- - - Process multipart/form-data. - - -
-
- - - Build an Edge Functions Counter with Upstash Redis. - - -
-
- - - Rate Limiting Edge Functions with Upstash Redis. - - -
-
- - - Slack Bot handling Slack mentions in Edge Function - - -
-
+ + + + + + + + + diff --git a/apps/docs/content/guides/functions/background-tasks.mdx b/apps/docs/content/guides/functions/background-tasks.mdx index c893c8227b6..607da02b5b0 100644 --- a/apps/docs/content/guides/functions/background-tasks.mdx +++ b/apps/docs/content/guides/functions/background-tasks.mdx @@ -9,7 +9,7 @@ Edge Function instances can process background tasks outside of the request hand This allows you to: -- Respond quickly to users while processing continues +- Respond to users while processing continues - Handle async operations without blocking the response --- diff --git a/apps/docs/content/guides/functions/deploy.mdx b/apps/docs/content/guides/functions/deploy.mdx index e5ccb12e30d..d1a05e34bf1 100644 --- a/apps/docs/content/guides/functions/deploy.mdx +++ b/apps/docs/content/guides/functions/deploy.mdx @@ -40,7 +40,7 @@ If you haven't yet created a Supabase project, you can do so by visiting [databa
-[Link](/docs/reference/cli/usage#supabase-link) your local project to your remote Supabase project using the ID you just retrieved: +[Link](/docs/reference/cli/usage#supabase-link) your local project to your remote Supabase project using the ID you retrieved: ```bash supabase link --project-ref your-project-id diff --git a/apps/docs/content/guides/functions/examples/semantic-search.mdx b/apps/docs/content/guides/functions/examples/semantic-search.mdx index b278fc12503..e2a29dfa470 100644 --- a/apps/docs/content/guides/functions/examples/semantic-search.mdx +++ b/apps/docs/content/guides/functions/examples/semantic-search.mdx @@ -136,4 +136,4 @@ export default { } ``` -You now have AI powered semantic search set up without any external dependencies! Just you, pgvector, and Supabase Edge Functions! +You now have AI powered semantic search set up without any external dependencies! All you need: you, pgvector, and Supabase Edge Functions! diff --git a/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx b/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx index 1e8b854fdbe..ea9007623b6 100644 --- a/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx +++ b/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx @@ -20,8 +20,9 @@ For your bot to seamlessly interact with Slack, you'll need to configure Slack A Deploy the following code as an Edge function using the CLI: ```bash -supabase --project-ref nacho_slacker secrets \ -set SLACK_TOKEN= +supabase secrets set \ + SLACK_TOKEN= \ + --project-ref nacho_slacker ``` Here's the code of the Edge Function, you can change the response to handle the text received: diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx index 45a0a7892cc..f0ff033351a 100644 --- a/apps/docs/content/guides/getting-started.mdx +++ b/apps/docs/content/guides/getting-started.mdx @@ -5,36 +5,7 @@ description: 'Resources for getting started with Supabase.' hideToc: true --- -
- -
- -
- - - Develop with Supabase AI-first using plugins, MCP, and skills. - - - - - Learn about the different API keys in Supabase and how to use them. - - - - - Use the Supabase CLI to develop locally and collaborate between teams. - - -
- -
- -
+ ### Use cases diff --git a/apps/docs/content/guides/getting-started/features.mdx b/apps/docs/content/guides/getting-started/features.mdx index d27f2ca8a6d..285be68ead4 100644 --- a/apps/docs/content/guides/getting-started/features.mdx +++ b/apps/docs/content/guides/getting-started/features.mdx @@ -34,7 +34,7 @@ Encrypt sensitive data and store secrets using our Postgres extension, Supabase ### Replication -Automatically replicate your database to destination systems like data warehouses and analytics platforms with external replication (ETL), powered by Supabase ETL. Read [the documentation](/docs/guides/database/replication/external-replication-setup) for more details. +Automatically replicate your database to destination systems like data warehouses and analytics platforms with Supabase Pipelines. Read [the documentation](/docs/guides/database/replication/pipelines) for more details. ## Platform diff --git a/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx b/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx index 1dd467b0fb3..ac53e7cecfa 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx @@ -2,188 +2,110 @@ title: 'Use Supabase with Astro' subtitle: 'Learn how to create a Supabase project, add sample data, and query from an Astro app.' breadcrumb: 'Framework Quickstarts' -hideToc: false --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create an Astro app - <$Partial path="quickstart_db_setup.mdx" /> +Create an Astro app using the `npm create` command. - +```bash +npm create astro@latest my-app +cd my-app +``` - +## 4. Install Supabase client library and Node adapter - +Install the `supabase-js` client library and the `@astrojs/node` adapter to enable server-side rendering. - - Create an Astro app using the `npm create` command. +```bash +npm install @supabase/supabase-js @astrojs/node +``` - <$Partial path="uiLibCta.mdx" /> +## 5. Configure Astro for SSR - +Update your `astro.config.mjs`. - +```js name=astro.config.mjs +import node from '@astrojs/node' +import { defineConfig } from 'astro/config' - ```bash name=Terminal - npm create astro@latest my-app - cd my-app - ``` +export default defineConfig({ + output: 'server', + adapter: node({ + mode: 'standalone', + }), +}) +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "astro", "tab": "frameworks" }} /> - +## 6. Declare Supabase environment variables - - +Create a `.env.local` file and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=astro&tab=frameworks): - Install the `supabase-js` client library and the `@astrojs/node` adapter to enable server-side rendering. +<$CodeTabs> - +```text name=.env.local +PUBLIC_SUPABASE_URL= +PUBLIC_SUPABASE_PUBLISHABLE_KEY= +``` - + - ```bash name=Terminal - npm install @supabase/supabase-js @astrojs/node - ``` +## 7. Create a Supabase client helper - +Create a utility file to initialize the Supabase client: - +```ts name=src/lib/supabase.ts +import { createClient } from '@supabase/supabase-js' - - +const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL +const supabasePublishableKey = import.meta.env.PUBLIC_SUPABASE_PUBLISHABLE_KEY - Update your `astro.config.mjs`. +export function createServerClient() { + return createClient(supabaseUrl, supabasePublishableKey) +} +``` - +## 8. Query Supabase data from Astro - +<$Partial path="uiLibCta.mdx" /> - ```js name=astro.config.mjs - import { defineConfig } from "astro/config"; - import node from "@astrojs/node"; +Create a new file at `src/pages/instruments.astro` and populate with the following. - export default defineConfig({ - output: "server", - adapter: node({ - mode: "standalone", - }), - }); - ``` +This queries all rows from the `instruments` table you created earlier and renders them on the page. - +```astro name=src/pages/instruments.astro +--- +import { createServerClient } from "../lib/supabase"; - +const supabase = createServerClient(); +const { data: instruments } = await supabase.from("instruments").select(); +--- - - + + + Instruments + + +
    + {instruments?.map((instrument) => ( +
  • {instrument.name}
  • + ))} +
+ + +``` - Create a `.env.local` file and populate with your Supabase connection variables: +## 9. Start the app - - +Run the development server, go to http://localhost:4321/instruments in your browser of choice to check the list of instruments. -
- - - - <$CodeTabs> - - ```text name=.env.local - PUBLIC_SUPABASE_URL= - PUBLIC_SUPABASE_PUBLISHABLE_KEY= - ``` - - - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "astro", "tab": "frameworks" }} /> - - - -
- - - - - Create a utility file to initialize the Supabase client: - - - - - - ```ts name=src/lib/supabase.ts - import { createClient } from "@supabase/supabase-js"; - - const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL - const supabasePublishableKey = import.meta.env.PUBLIC_SUPABASE_PUBLISHABLE_KEY - - export function createServerClient() { - return createClient( - supabaseUrl, - supabasePublishableKey - ); - } - ``` - - - - - - - - - Create a new file at `src/pages/instruments.astro` and populate with the following. - - This queries all rows from the `instruments` table in Supabase and renders them on the page. - - - - - - ```astro name=src/pages/instruments.astro - --- - import { createServerClient } from "../lib/supabase"; - - const supabase = createServerClient(); - const { data: instruments } = await supabase.from("instruments").select(); - --- - - - - Instruments - - -
    - {instruments?.map((instrument) => ( -
  • {instrument.name}
  • - ))} -
- - - ``` - -
- -
- - - - - Run the development server, go to http://localhost:4321/instruments in your browser of choice to check the list of instruments. - - - - - - ```bash name=Terminal - npm run dev - ``` - - - - -
+```bash +npm run dev +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/expo-react-native.mdx b/apps/docs/content/guides/getting-started/quickstarts/expo-react-native.mdx index a4f1b58e0a8..929196f8ea9 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/expo-react-native.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/expo-react-native.mdx @@ -2,191 +2,124 @@ title: 'Use Supabase with Expo React Native' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an Expo app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create an Expo app - <$Partial path="quickstart_db_setup.mdx" /> +Create a minimal Expo app using the `create-expo-app` command with the blank TypeScript template. - +```bash +npx create-expo-app my-app --template blank-typescript +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the `@supabase/supabase-js` client library which provides a convenient interface for working with Supabase from a React Native app. - Create a minimal Expo app using the `create-expo-app` command with the blank TypeScript template. +Navigate to the Expo app and install `supabase-js` along with the required dependencies for session storage and URL handling. - <$Partial path="uiLibCta.mdx" /> +```bash +cd my-app && npx expo install @supabase/supabase-js react-native-url-polyfill expo-sqlite +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> - +## 5. Declare Supabase environment variables - ```bash name=Terminal - npx create-expo-app my-app --template blank-typescript - ``` +Create a `.env` file in the root of your project and populate it with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true). - +Expo requires environment variables to be prefixed with `EXPO_PUBLIC_` to be accessible in your app code. - +```text name=.env +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +``` - - +## 6. Initialize the Supabase client - The fastest way to get started is to use the `@supabase/supabase-js` client library which provides a convenient interface for working with Supabase from a React Native app. +Create a helper file at `lib/supabase.ts` to initialize the Supabase client using the environment variables. - Navigate to the Expo app and install `supabase-js` along with the required dependencies for secure storage and URL handling. +The code below uses Expo's localStorage polyfill to persist authentication sessions. - +```ts name=lib/supabase.ts +import 'react-native-url-polyfill/auto' - +import { createClient } from '@supabase/supabase-js' - ```bash name=Terminal - cd my-app && npx expo install @supabase/supabase-js react-native-url-polyfill expo-sqlite - ``` +import 'expo-sqlite/localStorage/install' - +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL +const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY - +export const supabase = createClient(supabaseUrl, supabasePublishableKey, { + auth: { + storage: localStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, +}) +``` - - +## 7. Query data from the app - Create a `.env` file in the root of your project and populate it with your Supabase connection variables. +Replace the contents of `App.tsx` with the following code to fetch and display the instruments from your database. - Expo requires environment variables to be prefixed with `EXPO_PUBLIC_` to be accessible in your app code. +Use `useEffect` to fetch the data when the component mounts and display the query result using React Native components. - - +```tsx name=App.tsx +import { useEffect, useState } from 'react' +import { FlatList, StyleSheet, Text, View } from 'react-native' +import { supabase } from './lib/supabase' - +export default function App() { + const [instruments, setInstruments] = useState([]) - + useEffect(() => { + getInstruments() + }, []) - ```text name=.env - EXPO_PUBLIC_SUPABASE_URL= - EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY= - ``` - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "", "tab": "" }} /> + async function getInstruments() { + const { data } = await supabase.from('instruments').select() + setInstruments(data) + } - + return ( + + item.id.toString()} + renderItem={({ item }) => {item.name}} + /> + + ) +} - +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + paddingTop: 50, + paddingHorizontal: 16, + }, + item: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#ccc', + }, +}) +``` - - +## 8. Start the app - Create a helper file at `lib/supabase.ts` to initialize the Supabase client using the environment variables. +Run the development server and scan the QR code with the Expo Go app on your phone, or press `i` for iOS simulator or `a` for Android emulator. - The code below uses Expo's localStorage polyfill to persist authentication sessions. - - - - - - ```ts name=lib/supabase.ts - import 'react-native-url-polyfill/auto' - import { createClient } from '@supabase/supabase-js' - import 'expo-sqlite/localStorage/install'; - - const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL - const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY - - export const supabase = createClient(supabaseUrl, supabasePublishableKey, { - auth: { - storage: localStorage, - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false, - }, - }) - ``` - - - - - - - - - Replace the contents of `App.tsx` with the following code to fetch and display the instruments from your database. - - Use `useEffect` to fetch the data when the component mounts and display the query result using React Native components. - - - - - ```tsx name=App.tsx - import { useEffect, useState } from 'react' - import { StyleSheet, View, FlatList, Text } from 'react-native' - import { supabase } from './lib/supabase' - - export default function App() { - const [instruments, setInstruments] = useState([]) - - useEffect(() => { - getInstruments() - }, []) - - async function getInstruments() { - const { data } = await supabase.from('instruments').select() - setInstruments(data) - } - - return ( - - item.id.toString()} - renderItem={({ item }) => ( - {item.name} - )} - /> - - ) - } - - const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - paddingTop: 50, - paddingHorizontal: 16, - }, - item: { - padding: 16, - borderBottomWidth: 1, - borderBottomColor: '#ccc', - }, - }) - ``` - - - - - - - - - Run the development server and scan the QR code with the Expo Go app on your phone, or press `i` for iOS simulator or `a` for Android emulator. - - - - - - ```bash name=Terminal - npx expo start - ``` - - - - - - +```bash +npx expo start +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/flask.mdx b/apps/docs/content/guides/getting-started/quickstarts/flask.mdx index c20e845f9ea..5efde4856a6 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/flask.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/flask.mdx @@ -2,150 +2,93 @@ title: 'Use Supabase with Python' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Python app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a Python app with Flask - <$Partial path="quickstart_db_setup.mdx" /> +Create a new directory for your Python app and set up a virtual environment. - +```bash +mkdir my-app && cd my-app +python3 -m venv venv +source venv/bin/activate +``` - +## 4. Install Flask and the Supabase client library - +The fastest way to get started is to use Flask for the web framework and the `supabase-py` client library which provides a convenient interface for working with Supabase from a Python app. - Create a new directory for your Python app and set up a virtual environment. +Install both packages using pip. - +```bash +pip install flask supabase +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> - ```bash name=Terminal - mkdir my-app && cd my-app - python3 -m venv venv - source venv/bin/activate - ``` +## 5. Create environment variables file - +Create a `.env` file in your project root and populate it with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true): - +<$CodeTabs> - - +```text name=.env +SUPABASE_URL= +SUPABASE_PUBLISHABLE_KEY= +``` - The fastest way to get started is to use Flask for the web framework and the `supabase-py` client library which provides a convenient interface for working with Supabase from a Python app. + - Install both packages using pip. +## 6. Query data from the app - +Install the `python-dotenv` package to load environment variables: - +```bash +pip install python-dotenv +``` - ```bash name=Terminal - pip install flask supabase - ``` +Create an `app.py` file and add a route that fetches data from your `instruments` table using the Supabase client. - +```python name=app.py +import os +from flask import Flask +from supabase import create_client, Client +from dotenv import load_dotenv - +load_dotenv() - - +app = Flask(__name__) - Create a `.env` file in your project root and populate it with your Supabase connection variables: +supabase: Client = create_client( + os.environ.get("SUPABASE_URL"), + os.environ.get("SUPABASE_PUBLISHABLE_KEY") +) - - +@app.route('/') +def index(): + response = supabase.table('instruments').select("*").execute() + instruments = response.data + html = '

Instruments

    ' + for instrument in instruments: + html += f'
  • {instrument["name"]}
  • ' + html += '
' -
+ return html - +if __name__ == '__main__': + app.run(debug=True) +``` - <$CodeTabs> +## 7. Start the app - ```text name=.env - SUPABASE_URL= - SUPABASE_PUBLISHABLE_KEY= - ``` +Run the Flask development server, and go to http://localhost:5000 in your browser, you should see the list of instruments. - - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "", "tab": "" }} /> - - - -
- - - - - Install the `python-dotenv` package to load environment variables: - - ```bash - pip install python-dotenv - ``` - - Create an `app.py` file and add a route that fetches data from your `instruments` table using the Supabase client. - - - - - ```python name=app.py - import os - from flask import Flask - from supabase import create_client, Client - from dotenv import load_dotenv - - load_dotenv() - - app = Flask(__name__) - - supabase: Client = create_client( - os.environ.get("SUPABASE_URL"), - os.environ.get("SUPABASE_PUBLISHABLE_KEY") - ) - - @app.route('/') - def index(): - response = supabase.table('instruments').select("*").execute() - instruments = response.data - - html = '

Instruments

    ' - for instrument in instruments: - html += f'
  • {instrument["name"]}
  • ' - html += '
' - - return html - - if __name__ == '__main__': - app.run(debug=True) - ``` - -
- -
- - - - - Run the Flask development server, go to http://localhost:5000 in a browser and you should see the list of instruments. - - - - - - ```bash name=Terminal - python app.py - ``` - - - - -
+```bash +python app.py +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/flutter.mdx b/apps/docs/content/guides/getting-started/quickstarts/flutter.mdx index 498936ca67f..ab121a5d01c 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/flutter.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/flutter.mdx @@ -2,177 +2,115 @@ title: 'Use Supabase with Flutter' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Flutter app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a Flutter app - <$Partial path="quickstart_db_setup.mdx" /> +Create a Flutter app using the `flutter create` command. - +```bash +flutter create my_app +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the [`supabase_flutter`](https://pub.dev/packages/supabase_flutter) client library which provides a convenient interface for working with Supabase from a Flutter app. - Create a Flutter app using the `flutter create` command. You can skip this step if you already have a working app. +Open the `pubspec.yaml` file inside your Flutter app and add `supabase_flutter` as a dependency. - +```yaml name=pubspec.yaml +supabase_flutter: ^2.0.0 +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "flutter", "tab": "mobiles" }} /> - ```bash name=Terminal - flutter create my_app - ``` +## 5. Initialize the Supabase client - +Open `lib/main.dart` and edit the main function to initialize Supabase using your project URL and publishable key, which you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=flutter&tab=mobiles): - +```dart name=lib/main.dart +import 'package:supabase_flutter/supabase_flutter.dart'; - +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); - + await Supabase.initialize( + url: 'YOUR_SUPABASE_URL', + publishableKey: 'YOUR_SUPABASE_PUBLISHABLE_KEY', + ); + runApp(MyApp()); +} +``` - The fastest way to get started is to use the [`supabase_flutter`](https://pub.dev/packages/supabase_flutter) client library which provides a convenient interface for working with Supabase from a Flutter app. +## 6. Query data from the app - Open the `pubspec.yaml` file inside your Flutter app and add `supabase_flutter` as a dependency. +Use a `FutureBuilder` to fetch the data when the home page loads and display the query result in a `ListView`. - +Replace the default `MyApp` and `MyHomePage` classes with the following code. - +```dart name=lib/main.dart +class MyApp extends StatelessWidget { + const MyApp({super.key}); - ```yaml name=pubspec.yaml - supabase_flutter: ^2.0.0 - ``` + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: 'Instruments', + home: HomePage(), + ); + } +} - +class HomePage extends StatefulWidget { + const HomePage({super.key}); - + @override + State createState() => _HomePageState(); +} - +class _HomePageState extends State { + final _future = Supabase.instance.client + .from('instruments') + .select(); - - - Open `lib/main.dart` and edit the main function to initialize Supabase using your project URL and publishable key: - - - - - - - - - - ```dart name=lib/main.dart - import 'package:supabase_flutter/supabase_flutter.dart'; - - Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - - await Supabase.initialize( - url: 'YOUR_SUPABASE_URL', - publishableKey: 'YOUR_SUPABASE_PUBLISHABLE_KEY', - ); - runApp(MyApp()); - } - ``` - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "flutter", "tab": "mobiles" }} /> - - - - - - - - - - Use a `FutureBuilder` to fetch the data when the home page loads and display the query result in a `ListView`. - - Replace the default `MyApp` and `MyHomePage` classes with the following code. - - - - - - ```dart name=lib/main.dart - class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - title: 'Instruments', - home: HomePage(), + @override + Widget build(BuildContext context) { + return Scaffold( + body: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final instruments = snapshot.data!; + return ListView.builder( + itemCount: instruments.length, + itemBuilder: ((context, index) { + final instrument = instruments[index]; + return ListTile( + title: Text(instrument['name']), + ); + }), ); - } - } + }, + ), + ); + } +} +``` - class HomePage extends StatefulWidget { - const HomePage({super.key}); +## 7. Start the app - @override - State createState() => _HomePageState(); - } +Run your app on a platform of your choosing! By default an app should launch in your web browser. - class _HomePageState extends State { - final _future = Supabase.instance.client - .from('instruments') - .select(); +Note that `supabase_flutter` is compatible with web, iOS, Android, macOS, and Windows apps. +Running the app on macOS requires additional configuration to [set the entitlements](https://docs.flutter.dev/development/platform-integration/macos/building#setting-up-entitlements). - @override - Widget build(BuildContext context) { - return Scaffold( - body: FutureBuilder( - future: _future, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final instruments = snapshot.data!; - return ListView.builder( - itemCount: instruments.length, - itemBuilder: ((context, index) { - final instrument = instruments[index]; - return ListTile( - title: Text(instrument['name']), - ); - }), - ); - }, - ), - ); - } - } - ``` - - - - - - - - - Run your app on a platform of your choosing! By default an app should launch in your web browser. - - Note that `supabase_flutter` is compatible with web, iOS, Android, macOS, and Windows apps. - Running the app on macOS requires additional configuration to [set the entitlements](https://docs.flutter.dev/development/platform-integration/macos/building#setting-up-entitlements). - - - - - - ```bash name=Terminal - flutter run - ``` - - - - - - +```bash +flutter run +``` ## Setup deep links diff --git a/apps/docs/content/guides/getting-started/quickstarts/hono.mdx b/apps/docs/content/guides/getting-started/quickstarts/hono.mdx index 4f81cce2f7f..6e20b75f2f3 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/hono.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/hono.mdx @@ -2,91 +2,48 @@ title: 'Use Supabase with Hono' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, secure it with auth, and query the data from a Hono app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a Hono app - +Bootstrap the Hono example app from the Supabase Samples using the CLI. - Bootstrap the Hono example app from the Supabase Samples using the CLI. +```bash +npx supabase@latest bootstrap hono +``` - +## 4. Install the Supabase client library - +The `package.json` file in the project includes the necessary dependencies, including `@supabase/supabase-js` and `@supabase/ssr` to help with server-side auth. - ```bash name=Terminal - npx supabase@latest bootstrap hono - ``` - - - - - - - - - The `package.json` file in the project includes the necessary dependencies, including `@supabase/supabase-js` and `@supabase/ssr` to help with server-side auth. - - - - - - ```bash name=Terminal - npm install - ``` - - - - - - - - - Copy the `.env.example` file to `.env` and update the values with your Supabase project URL and publishable key. - - Lastly, [enable anonymous sign-ins](/dashboard/project/_/auth/providers) in the Auth settings. - - - - - - - - - - ```bash name=Terminal - cp .env.example .env - ``` +```bash +npm install +``` {/* TODO: Not ideal for frameworks that have no entry in Connect */} -<$Partial path="api_settings_steps.mdx" variables={{ "framework": "", "tab": "frameworks" }} /> +<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "frameworks" }} /> - +## 5. Set up the required environment variables - +Copy the `.env.example` file to `.env` and update the values with your Supabase project URL and publishable key, which you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&tab=frameworks). - - +Lastly, [enable anonymous sign-ins](/dashboard/project/_/auth/providers) in the Auth settings. - Start the app, go to http://localhost:5173. +```bash +cp .env.example .env +``` - Learn how [server side auth](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=hono) works with Hono. +## 6. Start the app - +Start the app, go to http://localhost:5173. - +Learn how [server side auth](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=hono) works with Hono. - ```bash name=Terminal - npm run dev - ``` - - - - - +```bash +npm run dev +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx b/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx index d19a1ddb758..9ca27640d1e 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/ios-swiftui.mdx @@ -2,146 +2,86 @@ title: 'Use Supabase with iOS and SwiftUI' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an iOS app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create an iOS SwiftUI app with Xcode - <$Partial path="quickstart_db_setup.mdx" /> +Select the **Xcode > New Project > iOS > App** menu item. - +## 4. Install the Supabase client library - +Add the [supabase-swift](https://github.com/supabase/supabase-swift) package to your app using the Swift Package Manager. - +In Xcode, navigate to **File > Add Package Dependencies...** and enter the repository URL `https://github.com/supabase/supabase-swift` in the search bar. For detailed instructions, see Apple's [tutorial on adding package dependencies](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). - Open Xcode > New Project > iOS > App. You can skip this step if you already have a working app. +Make sure to add `Supabase` product package as a dependency to your application target. - +<$Partial path="api_settings.mdx" variables={{ "framework": "swift", "tab": "mobiles" }} /> - +## 5. Initialize the Supabase client - +Create a new `Supabase.swift` file add a new Supabase instance using your project URL and publishable key, which you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=swift&tab=mobiles): - +```swift name=Supabase.swift +import Supabase - Add the [supabase-swift](https://github.com/supabase/supabase-swift) package to your app using the Swift Package Manager. +let supabase = SupabaseClient( + supabaseURL: URL(string: "YOUR_SUPABASE_URL")!, + supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY" +) +``` - In Xcode, navigate to **File > Add Package Dependencies...** and enter the repository URL `https://github.com/supabase/supabase-swift` in the search bar. For detailed instructions, see Apple's [tutorial on adding package dependencies](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). +## 6. Create a data model for instruments - Make sure to add `Supabase` product package as a dependency to your application target. +Create a decodable struct to deserialize the data from the database. - +Add the following code to a new file named `Instrument.swift`. - +```swift name=Instrument.swift +struct Instrument: Decodable, Identifiable { + let id: Int + let name: String +} +``` - +## 7. Query data from the app - +Use a `task` to fetch the data from the database and display it using a `List`. - Create a new `Supabase.swift` file add a new Supabase instance using your project URL and publishable key: +Replace the default `ContentView` with the following code. - - +```swift name=ContentView.swift +import SwiftUI +struct ContentView: View { - + @State var instruments: [Instrument] = [] - - - ```swift name=Supabase.swift - import Supabase - - let supabase = SupabaseClient( - supabaseURL: URL(string: "YOUR_SUPABASE_URL")!, - supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY" - ) - ``` - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "swift", "tab": "mobiles" }} /> - - - - - - - - - - Create a decodable struct to deserialize the data from the database. - - Add the following code to a new file named `Instrument.swift`. - - - - - - ```swift name=Instrument.swift - struct Instrument: Decodable, Identifiable { - let id: Int - let name: String + var body: some View { + List(instruments) { instrument in + Text(instrument.name) + } + .overlay { + if instruments.isEmpty { + ProgressView() } - ``` - - - - - - - - - - Use a `task` to fetch the data from the database and display it using a `List`. - - Replace the default `ContentView` with the following code. - - - - - - ```swift name=ContentView.swift - import SwiftUI - - struct ContentView: View { - - @State var instruments: [Instrument] = [] - - var body: some View { - List(instruments) { instrument in - Text(instrument.name) - } - .overlay { - if instruments.isEmpty { - ProgressView() - } - } - .task { - do { - instruments = try await supabase.from("instruments").select().execute().value - } catch { - dump(error) - } - } - } + } + .task { + do { + instruments = try await supabase.from("instruments").select().execute().value + } catch { + dump(error) } - ``` + } + } +} +``` - +## 8. Start the app - - - - - - Run the app on a simulator or a physical device by hitting `Cmd + R` on Xcode. - - - - - - +Run the app on a simulator or a physical device by hitting `Cmd + R` on Xcode. ## Setting up deep links diff --git a/apps/docs/content/guides/getting-started/quickstarts/kotlin.mdx b/apps/docs/content/guides/getting-started/quickstarts/kotlin.mdx index bf300112b3f..d712b5f87a0 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/kotlin.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/kotlin.mdx @@ -2,189 +2,139 @@ title: 'Use Supabase with Android Kotlin' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an Android Kotlin app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create an Android app with Android Studio - <$Partial path="quickstart_db_setup.mdx" /> +Select the **Android Studio > New > New Android Project** menu item. - +## 4. Install dependencies - +Open `build.gradle.kts` (app) file and add the serialization plugin, Ktor client, and Supabase client. - - Open Android Studio > New > New Android Project. +Replace the version placeholders `$kotlin_version` with the Kotlin version of the project, and `$supabase_version` and `$ktor_version` with the respective latest versions. - + - +You can find the latest supabase-kt version [on GitHub](https://github.com/supabase-community/supabase-kt/releases) and Ktor [in the Ktor documentation](https://ktor.io/docs/welcome.html). - +
- - Open `build.gradle.kts` (app) file and add the serialization plug, Ktor client, and Supabase client. +```kotlin +plugins { + ... + kotlin("plugin.serialization") version "$kotlin_version" +} +... +dependencies { + ... + implementation(platform("io.github.jan-tennert.supabase:bom:$supabase_version")) + implementation("io.github.jan-tennert.supabase:postgrest-kt") + implementation("io.ktor:ktor-client-android:$ktor_version") +} +``` - Replace the version placeholders `$kotlin_version` with the Kotlin version of the project, and `$supabase_version` and `$ktor_version` with the respective latest versions. +## 5. Add internet access permission - The latest supabase-kt version can be found [here](https://github.com/supabase-community/supabase-kt/releases) and Ktor version can be found [here](https://ktor.io/docs/welcome.html). +Add the following line to the `AndroidManifest.xml` file under the `manifest` tag and outside the `application` tag. - +```xml +... + +... +``` - - ```kotlin - plugins { - ... - kotlin("plugin.serialization") version "$kotlin_version" - } - ... - dependencies { - ... - implementation(platform("io.github.jan-tennert.supabase:bom:$supabase_version")) - implementation("io.github.jan-tennert.supabase:postgrest-kt") - implementation("io.ktor:ktor-client-android:$ktor_version") - } - ``` +<$Partial path="api_settings.mdx" variables={{ "framework": "androidkotlin", "tab": "mobiles" }} /> - +## 6. Initialize the Supabase client - +You can create a Supabase client whenever you need to perform an API call. - +For a quick example, create a client at the top of the `MainActivity.kt` file below the imports. - - Add the following line to the `AndroidManifest.xml` file under the `manifest` tag and outside the `application` tag. - +Replace the `supabaseUrl` and `supabaseKey` with your own, which you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=androidkotlin&tab=mobiles): - - ```xml - ... - - ... - ``` +```kotlin +import ... - +val supabase = createSupabaseClient( + supabaseUrl = "https://xyzcompany.supabase.co", + supabaseKey = "your_publishable_key" + ) { + install(Postgrest) +} +... +``` - +## 7. Create a data model for instruments - +Create a serializable data class to represent the data from the database. - - You can create a Supabase client whenever you need to perform an API call. +Add the following below the `createSupabaseClient` function in the `MainActivity.kt` file. - For the sake of simplicity, we will create a client in the `MainActivity.kt` file at the top just below the imports. +```kotlin +@Serializable +data class Instrument( + val id: Int, + val name: String, +) +``` - Replace the `supabaseUrl` and `supabaseKey` with your own: +## 8. Query data from the app - - +Use `LaunchedEffect` to fetch data from the database and display it in a `LazyColumn`. +Replace the default `MainActivity` class with the following code. - + - +This example application makes a network request from the UI code. In production, you should use a `ViewModel` to separate the UI and data fetching logic. - ```kotlin - import ... + - val supabase = createSupabaseClient( - supabaseUrl = "https://xyzcompany.supabase.co", - supabaseKey = "your_publishable_key" - ) { - install(Postgrest) - } - ... - ``` +```kotlin +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SupabaseTutorialTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + InstrumentsList() + } + } + } + } +} - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "androidkotlin", "tab": "mobiles" }} /> +@Composable +fun InstrumentsList() { + var instruments by remember { mutableStateOf>(listOf()) } + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + instruments = supabase.from("instruments") + .select().decodeList() + } + } + LazyColumn { + items( + instruments, + key = { instrument -> instrument.id }, + ) { instrument -> + Text( + instrument.name, + modifier = Modifier.padding(8.dp), + ) + } + } +} +``` - +## 9. Start the app - - - - - - Create a serializable data class to represent the data from the database. - - Add the following below the `createSupabaseClient` function in the `MainActivity.kt` file. - - - - ```kotlin - @Serializable - data class Instrument( - val id: Int, - val name: String, - ) - ``` - - - - - - - - - Use `LaunchedEffect` to fetch data from the database and display it in a `LazyColumn`. - - Replace the default `MainActivity` class with the following code. - - Note that we are making a network request from our UI code. In production, you should probably use a `ViewModel` to separate the UI and data fetching logic. - - - - ```kotlin - class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - SupabaseTutorialTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - InstrumentsList() - } - } - } - } - } - - @Composable - fun InstrumentsList() { - var instruments by remember { mutableStateOf>(listOf()) } - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - instruments = supabase.from("instruments") - .select().decodeList() - } - } - LazyColumn { - items( - instruments, - key = { instrument -> instrument.id }, - ) { instrument -> - Text( - instrument.name, - modifier = Modifier.padding(8.dp), - ) - } - } - } - ``` - - - - - - - Run the app on an emulator or a physical device by clicking the `Run app` button in Android Studio. - - - - - +Run the app on an emulator or a physical device by clicking the `Run app` button in Android Studio. diff --git a/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx b/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx index 6e7744279bf..d66857b498b 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/laravel.mdx @@ -2,144 +2,87 @@ title: 'Use Supabase with Laravel' subtitle: 'Learn how to create a PHP Laravel project, connect it to your Supabase Postgres database, and configure user authentication.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - - +## 3. Create a Laravel project - Make sure your PHP and Composer versions are up to date, then use `composer create-project` to scaffold a new Laravel project. +Make sure your PHP and Composer versions are up to date, then use `composer create-project` to scaffold a new Laravel project. - See the [Laravel docs](https://laravel.com/docs/10.x/installation#creating-a-laravel-project) for more details. +See the [Laravel docs](https://laravel.com/docs/10.x/installation#creating-a-laravel-project) for more details. - +```bash +composer create-project laravel/laravel example-app +``` - +## 4. Install the authentication template - ```bash name=Terminal - composer create-project laravel/laravel example-app - ``` +Install [Laravel Breeze](https://laravel.com/docs/10.x/starter-kits#laravel-breeze), a basic implementation of all of Laravel's [authentication features](https://laravel.com/docs/10.x/authentication). - +```bash +composer require laravel/breeze --dev +php artisan breeze:install +``` - +## 5. Set up the Postgres connection details - - +Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely. - Install [Laravel Breeze](https://laravel.com/docs/10.x/starter-kits#laravel-breeze), a basic implementation of all of Laravel's [authentication features](https://laravel.com/docs/10.x/authentication). +When your project is up and running, navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&method=session). - +Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password. You can reset your database password in your [Database Settings](/dashboard/project/_/database/settings) if you do not have it. - + - ```bash name=Terminal - composer require laravel/breeze --dev - php artisan breeze:install - ``` +If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode. - + - +```bash name=.env +DB_CONNECTION=pgsql +DB_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres +``` - - +## 6. Change the default schema - Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely. +By default Laravel uses the `public` schema. We recommend changing this as Supabase exposes the `public` schema as a [data API](/docs/guides/api). - When your project is up and running, navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&method=session). +You can change the schema of your Laravel application by modifying the `search_path` variable `app/config/database.php`. - Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password. You can reset your database password in your [Database Settings](/dashboard/project/_/database/settings) if you do not have it. +The schema you specify in `search_path` has to exist on Supabase. You can create a new schema from the [Table Editor](/dashboard/project/_/editor). - +```php name=app/config/database.php +'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'laravel', + 'sslmode' => 'prefer', +], +``` - If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode. +## 7. Run the database migrations - +Laravel ships with database migration files that set up the required tables for Laravel Authentication and User Management. - +Note: Laravel does not use Supabase Auth but rather implements its own authentication system! - +```bash +php artisan migrate +``` - ```bash name=.env - DB_CONNECTION=pgsql - DB_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres - ``` +## 8. Start the app - +Run the development server. Go to http://127.0.0.1:8000 in a browser to see your application. You can also navigate to http://127.0.0.1:8000/register and http://127.0.0.1:8000/login to register and log in users. - - - - - - By default Laravel uses the `public` schema. We recommend changing this as Supabase exposes the `public` schema as a [data API](/docs/guides/api). - - You can change the schema of your Laravel application by modifying the `search_path` variable `app/config/database.php`. - - The schema you specify in `search_path` has to exist on Supabase. You can create a new schema from the [Table Editor](/dashboard/project/_/editor). - - - - - - ```php name=app/config/database.php - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'laravel', - 'sslmode' => 'prefer', - ], - ``` - - - - - - - - - Laravel ships with database migration files that set up the required tables for Laravel Authentication and User Management. - - Note: Laravel does not use Supabase Auth but rather implements its own authentication system! - - - - - - ```bash name=Terminal - php artisan migrate - ``` - - - - - - - - - Run the development server. Go to http://127.0.0.1:8000 in a browser to see your application. You can also navigate to http://127.0.0.1:8000/register and http://127.0.0.1:8000/login to register and log in users. - - - - - - ```bash name=Terminal - php artisan serve - ``` - - - - - - +```bash +php artisan serve +``` diff --git a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx index fab3593778d..e3b46fd890e 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx @@ -2,125 +2,72 @@ title: 'Use Supabase with Next.js' subtitle: 'Learn how to create a Supabase project, add some sample data, and query from a Next.js app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a Next.js app - <$Partial path="quickstart_db_setup.mdx" /> +Use the `create-next-app` command and the `with-supabase` template, to create a Next.js app pre-configured with [Cookie-based Auth](/docs/guides/auth/server-side/creating-a-client?queryGroups=package-manager&package-manager=npm&queryGroups=framework&framework=nextjs&queryGroups=environment&environment=server), [TypeScript](https://www.typescriptlang.org/), and [Tailwind CSS](https://tailwindcss.com/). - +```bash +npx create-next-app -e with-supabase +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} /> - +## 4. Declare Supabase environment variables - Use the `create-next-app` command and the `with-supabase` template, to create a Next.js app pre-configured with: - - [Cookie-based Auth](/docs/guides/auth/server-side/creating-a-client?queryGroups=package-manager&package-manager=npm&queryGroups=framework&framework=nextjs&queryGroups=environment&environment=server) - - [TypeScript](https://www.typescriptlang.org/) - - [Tailwind CSS](https://tailwindcss.com/) +Rename `.env.example` to `.env.local` and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=nextjs&tab=frameworks): - <$Partial path="uiLibCta.mdx" /> +<$CodeTabs> - +```text name=.env.local +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +``` - + - ```bash - npx create-next-app -e with-supabase - ``` +## 5. Query Supabase data from Next.js - +<$Partial path="uiLibCta.mdx" /> - +Create a new file at `app/instruments/page.tsx` and populate with the following. - - +This selects all the rows from the `instruments` table you created earlier and renders them on the page. - Rename `.env.example` to `.env.local` and populate with your Supabase connection variables: +<$CodeTabs> - - +```ts name=app/instruments/page.tsx +import { createClient } from "@/lib/supabase/server"; +import { Suspense } from "react"; +async function InstrumentsData() { + const supabase = await createClient(); + const { data: instruments } = await supabase.from("instruments").select(); - + return
{JSON.stringify(instruments, null, 2)}
; +} - +export default function Instruments() { + return ( + Loading instruments...
}> + + + ); +} +``` - <$CodeTabs> + - ```text name=.env.local - NEXT_PUBLIC_SUPABASE_URL= - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= - ``` +## 6. Start the app - +Run the development server, go to http://localhost:3000/instruments in a browser and you should see the list of instruments. - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} /> - - - - - - - - - Create a new file at `app/instruments/page.tsx` and populate with the following. - - This selects all the rows from the `instruments` table in Supabase and render them on the page. - - - - - - <$CodeTabs> - - ```ts name=app/instruments/page.tsx - import { createClient } from "@/lib/supabase/server"; - import { Suspense } from "react"; - - async function InstrumentsData() { - const supabase = await createClient(); - const { data: instruments } = await supabase.from("instruments").select(); - - return
{JSON.stringify(instruments, null, 2)}
; - } - - export default function Instruments() { - return ( - Loading instruments...
}> - - - ); - } - ``` - - - - - - - - - - - Run the development server, go to http://localhost:3000/instruments in a browser and you should see the list of instruments. - - - - - - ```bash Terminal - npm run dev - ``` - - - - - - +```bash +npm run dev +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx index cb1ce765b79..150f90b437e 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx @@ -2,148 +2,90 @@ title: 'Use Supabase with Nuxt' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Nuxt app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a Nuxt app - <$Partial path="quickstart_db_setup.mdx" /> +Create a Nuxt app using the `npx nuxi` command. - +```bash +npx nuxi@latest init my-app +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a Nuxt app. - - Create a Nuxt app using the `npx nuxi` command. +Navigate to the Nuxt app and install `supabase-js`. - <$Partial path="uiLibCta.mdx" /> +```bash +cd my-app && npm install @supabase/supabase-js +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "nuxt", "tab": "frameworks" }} /> - +## 5. Declare Supabase environment variables - ```bash name=Terminal - npx nuxi@latest init my-app - ``` +Create a `.env` file and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=nuxt&tab=frameworks): - +<$CodeTabs> - +```text name=.env +SUPABASE_URL= +SUPABASE_PUBLISHABLE_KEY= +``` - - +```ts name=nuxt.config.ts +export default defineNuxtConfig({ + runtimeConfig: { + public: { + supabaseUrl: process.env.SUPABASE_URL, + supabasePublishableKey: process.env.SUPABASE_PUBLISHABLE_KEY, + }, + }, +}) +``` - The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a Nuxt app. + - Navigate to the Nuxt app and install `supabase-js`. +## 6. Query data from the app - +In `app.vue`, create a Supabase client using your config values and replace the existing content with the following code. - +```vue name=app.vue + - - + +``` - Create a `.env` file and populate with your Supabase connection variables: +## 7. Start the app - - +Start the app, navigate to http://localhost:3000 in the browser, and you should see the list of instruments. - - - - - - <$CodeTabs> - - ```text name=.env.local - SUPABASE_URL= - SUPABASE_PUBLISHABLE_KEY= - ``` - - ```ts name=nuxt.config.tsx - export default defineNuxtConfig({ - runtimeConfig: { - public: { - supabaseUrl: process.env.SUPABASE_URL, - supabasePublishableKey: process.env.SUPABASE_PUBLISHABLE_KEY, - }, - }, - }); - ``` - - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "nuxt", "tab": "frameworks" }} /> - - - - - - - - - In `app.vue`, create a Supabase client using your config values and replace the existing content with the following code. - - - - - - ```vue name=app.vue - - - - ``` - - - - - - - - - Start the app, navigate to http://localhost:3000 in the browser, open the browser console, and you should see the list of instruments. - - - - - - ```bash name=Terminal - npm run dev - ``` - - - - - +```bash +npm run dev +``` diff --git a/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx index 66d9743c162..5e8e29d920b 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx @@ -2,149 +2,91 @@ title: 'Use Supabase with React' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a React app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a React app - <$Partial path="quickstart_db_setup.mdx" /> +Create a React app using a [Vite](https://vitejs.dev/guide/) template. - +```bash +npm create vite@latest my-app -- --template react +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the `supabase-js` client library, which provides a convenient interface for working with Supabase from a React app. - - Create a React app using a [Vite](https://vitejs.dev/guide/) template. +Navigate to the React app and install `supabase-js`. - <$Partial path="uiLibCta.mdx" /> +```bash +cd my-app && npm install @supabase/supabase-js +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "react", "tab": "frameworks" }} /> - +## 5. Declare Supabase environment variables - ```bash name=Terminal - npm create vite@latest my-app -- --template react - ``` +Create a `.env.local` file and populate it with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=react&tab=frameworks): - +```text name=.env.local +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` - +## 6. Query data from the app - - +<$Partial path="uiLibCta.mdx" /> - The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a React app. +Replace the contents of `App.jsx` with a `getInstruments` function that fetches the data and displays the query result on the page using a Supabase client. - Navigate to the React app and install `supabase-js`. +```js name=src/App.jsx +import { createClient } from '@supabase/supabase-js' +import { useEffect, useState } from 'react' - +const supabase = createClient( + import.meta.env.VITE_SUPABASE_URL, + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY +) - +function App() { + const [instruments, setInstruments] = useState([]) - ```bash name=Terminal - cd my-app && npm install @supabase/supabase-js - ``` + useEffect(() => { + getInstruments() + }, []) - + async function getInstruments() { + const { data, error } = await supabase.from('instruments').select() - + if (error) { + console.error(error) + return + } - - + setInstruments(data) + } - Create a `.env.local` file and populate with your Supabase connection variables: + return ( +
    + {instruments.map((instrument) => ( +
  • {instrument.name}
  • + ))} +
+ ) +} - - +export default App +``` +## 7. Start the app -
+Run the development server, go to http://localhost:5173 in a browser, and you should see the list of instruments. - - - <$CodeTabs> - - ```text name=.env.local - VITE_SUPABASE_URL= - VITE_SUPABASE_PUBLISHABLE_KEY= - ``` - - - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "react", "tab": "frameworks" }} /> - - - -
- - - - - Replace the contents of `App.jsx` to add a `getInstruments` function to fetch the data and display the query result to the page using a Supabase client. - - - - - ```js name=src/App.jsx - import { useEffect, useState } from "react"; - import { createClient } from "@supabase/supabase-js"; - - const supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY); - - function App() { - const [instruments, setInstruments] = useState([]); - - useEffect(() => { - getInstruments(); - }, []); - - async function getInstruments() { - const { data, error } = await supabase.from("instruments").select(); - - if (error) { - console.error(error); - return; - } - - setInstruments(data); - } - - return ( -
    - {instruments.map((instrument) => ( -
  • {instrument.name}
  • - ))} -
- ); - } - - export default App; - ``` - -
- -
- - - - - Run the development server, go to http://localhost:5173 in a browser and you should see the list of instruments. - - - - - - ```bash name=Terminal - npm run dev - ``` - - - - -
+```bash +npm run dev +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx index 661521fefbc..d98de59234b 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx @@ -2,230 +2,154 @@ title: 'Use Supabase with RedwoodJS' subtitle: 'Learn how to create a Supabase project, add some sample data to your database using Prisma migration and seeds, and query the data from a RedwoodJS app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +## 1. Setup your new Supabase project - - - [Create a new project](/dashboard) in the Supabase Dashboard. +[Create a new project](/dashboard) in the Supabase Dashboard. - + - Be sure to make note of the Database Password you used as you will need this later to connect to your database. +Be sure to make note of the Database Password you used as you will need this later to connect to your database. - - +
- - ![New project for redwoodjs](/docs/img/guides/getting-started/quickstarts/redwoodjs/new-project.png) - +![New project for redwoodjs](/docs/img/guides/getting-started/quickstarts/redwoodjs/new-project.png) - +## 2. Gather database connection strings - - +Open the project [**Connect** panel](/dashboard/project/_?showConnect=true). This quickstart connects using the [**Transaction pooler**](/dashboard/project/_?showConnect=true&method=transaction) and [**Session pooler**](/dashboard/project/_?showConnect=true&method=session) mode. Transaction mode is used for application queries and Session mode is used for running migrations with Prisma. - Open the project [**Connect** panel](/dashboard/project/_?showConnect=true). This quickstart connects using the [**Transaction pooler**](/dashboard/project/_?showConnect=true&method=transaction) and [**Session pooler**](/dashboard/project/_?showConnect=true&method=session) mode. Transaction mode is used for application queries and Session mode is used for running migrations with Prisma. +To do this, set the connection mode to `Transaction` in the [Database Settings page](/dashboard/project/_/database/settings) and copy the connection string and append `?pgbouncer=true&connection_limit=1`. `pgbouncer=true` disables Prisma from generating prepared statements. This is required since our connection pooler does not support prepared statements in transaction mode yet. The `connection_limit=1` parameter is only required if you are using Prisma from a serverless environment. This is the Transaction mode connection string. - To do this, set the connection mode to `Transaction` in the [Database Settings page](/dashboard/project/_/database/settings) and copy the connection string and append `?pgbouncer=true&&connection_limit=1`. `pgbouncer=true` disables Prisma from generating prepared statements. This is required since our connection pooler does not support prepared statements in transaction mode yet. The `connection_limit=1` parameter is only required if you are using Prisma from a serverless environment. This is the Transaction mode connection string. +To get the Session mode connection pooler string, change the port of the connection string from the dashboard to 5432. - To get the Session mode connection pooler string, change the port of the connection string from the dashboard to 5432. +You will need the Transaction mode connection string and the Session mode connection string to set up environment variables in Step 4. - You will need the Transaction mode connection string and the Session mode connection string to setup environment variables in Step 5. + - +You can copy and paste these connection strings from the Supabase Dashboard when needed in later steps. - You can copy and paste these connection strings from the Supabase Dashboard when needed in later steps. + - - +![pooled connection for redwoodjs](/docs/img/guides/getting-started/quickstarts/redwoodjs/pooled-connection-strings.png) - - ![pooled connection for redwoodjs](/docs/img/guides/getting-started/quickstarts/redwoodjs/pooled-connection-strings.png) - +## 3. Create a RedwoodJS app - +Create a RedwoodJS app with TypeScript. - - - Create a RedwoodJS app with TypeScript. + - +The [`yarn` package manager](https://yarnpkg.com) is required to create a RedwoodJS app. You will use it to run RedwoodJS commands later. - The [`yarn` package manager](https://yarnpkg.com) is required to create a RedwoodJS app. You will use it to run RedwoodJS commands later. +While TypeScript is recommended, If you want a JavaScript app, omit the `--ts` flag. - While TypeScript is recommended, If you want a JavaScript app, omit the `--ts` flag. + - - +```bash +yarn create redwood-app my-app --ts +``` - - ```bash name=Terminal - yarn create redwood-app my-app --ts - ``` - +## 4. Configure environment variables - +In your `.env` file, add the following environment variables for your database connection: - - - You'll develop your app, manage database migrations, and run your app in VS Code. - +- The `DATABASE_URL` should use the Transaction mode connection string you copied in Step 2. - - ```bash name=Terminal - cd my-app - code . - ``` - +- The `DIRECT_URL` should use the Session mode connection string you copied in Step 2. - +```bash name=.env +# Transaction mode connection string used for migrations +DATABASE_URL="postgres://postgres.[project-ref]:[db-password]@xxx.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1" - - +# Session mode connection string — used by Prisma Client +DIRECT_URL="postgres://postgres.[project-ref]:[db-password]@xxx.pooler.supabase.com:5432/postgres" +``` - In your `.env` file, add the following environment variables for your database connection: +## 5. Update your Prisma schema - * The `DATABASE_URL` should use the Transaction mode connection string you copied in Step 1. +By default, RedwoodJS ships with a SQLite database, but we want to use Postgres. - * The `DIRECT_URL` should use the Session mode connection string you copied in Step 1. +Update your Prisma schema file `api/db/schema.prisma` to use your Supabase Postgres database connection environment variables you set up in Step 4. - +```prisma name=api/db/schema.prisma +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") +} +``` - - ```bash name=.env - # Transaction mode connection string used for migrations - DATABASE_URL="postgres://postgres.[project-ref]:[db-password]@xxx.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1" +## 6. Create the instrument model and apply a schema migration - # Session mode connection string — used by Prisma Client - DIRECT_URL="postgres://postgres.[project-ref]:[db-password]@xxx.pooler.supabase.com:5432/postgres" - ``` - +Create the Instrument model in `api/db/schema.prisma` and then run `yarn rw prisma migrate dev` from your terminal to apply the migration. - +```prisma name=api/db/schema.prisma +model Instrument { + id Int @id @default(autoincrement()) + name String @unique +} +``` - - - By default, RedwoodJS ships with a SQLite database, but we want to use Postgres. +## 7. Update seed script - Update your Prisma schema file `api/db/schema.prisma` to use your Supabase Postgres database connection environment variables you setup in Step 5. - +Seed the database with a few instruments. - - ```prisma name=api/db/schema.prisma - datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") - } - ``` - +Update the file `scripts/seed.ts` to contain the following code: - +```ts name=scripts/seed.ts +import type { Prisma } from '@prisma/client' +import { db } from 'api/src/lib/db' - - - Create the Instrument model in `api/db/schema.prisma` and then run `yarn rw prisma migrate dev` from your terminal to apply the migration. - +export default async () => { + try { + const data: Prisma.InstrumentCreateArgs['data'][] = [ + { name: 'dulcimer' }, + { name: 'harp' }, + { name: 'guitar' }, + ] - - ```prisma name=api/db/schema.prisma - model Instrument { - id Int @id @default(autoincrement()) - name String @unique - } - ``` - + console.log('Seeding instruments ...') - + const instruments = await db.instrument.createMany({ data }) - - - Seed the database with a few instruments. + console.log('Done.', instruments) + } catch (error) { + console.error(error) + } +} +``` - Update the file `scripts/seed.ts` to contain the following code: - +## 8. Seed your database - +Run the seed database command to populate the `Instrument` table with the instruments you created. - ```ts name=scripts/seed.ts - import type { Prisma } from '@prisma/client' - import { db } from 'api/src/lib/db' + - export default async () => { - try { - const data: Prisma.InstrumentCreateArgs['data'][] = [ - { name: 'dulcimer' }, - { name: 'harp' }, - { name: 'guitar' }, - ] +The reset database command `yarn rw prisma db reset` recreates the tables and also runs the seed script. - console.log('Seeding instruments ...') + - const instruments = await db.instrument.createMany({ data }) +```bash +yarn rw prisma db seed +``` - console.log('Done.', instruments) - } catch (error) { - console.error(error) - } - } - ``` - +## 9. Scaffold the instrument UI - +Use RedwoodJS generators to scaffold a CRUD UI for the `Instrument` model. - - - Run the seed database command to populate the `Instrument` table with the instruments you just created. +```bash +yarn rw g scaffold instrument +``` - +## 10. Start the app - The reset database command `yarn rw prisma db reset` will recreate the tables and will also run the seed script. +Start the app via `yarn rw dev`. A browser will open to the RedwoodJS Splash page. - - +![RedwoodJS Splash Page](/docs/img/redwoodjs-qs-splash.png) - - ```bash name=Terminal - yarn rw prisma db seed - ``` - +## 11. View instruments UI - +Click on `/instruments` to visit http://localhost:8910/instruments where should see the list of instruments. - - - Now, we'll use RedwoodJS generators to scaffold a CRUD UI for the `Instrument` model. - - - - ```bash name=Terminal - yarn rw g scaffold instrument - ``` - - - - - - - Start the app via `yarn rw dev`. A browser will open to the RedwoodJS Splash page. - - - - ![RedwoodJS Splash Page](/docs/img/redwoodjs-qs-splash.png) - - - - - - - - Click on `/instruments` to visit http://localhost:8910/instruments where should see the list of instruments. - - You may now edit, delete, and add new books using the scaffolded UI. - - - - +You may now edit, delete, and add new instruments using the scaffolded UI. diff --git a/apps/docs/content/guides/getting-started/quickstarts/refine.mdx b/apps/docs/content/guides/getting-started/quickstarts/refine.mdx index 1706c86e717..deee438d6c2 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/refine.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/refine.mdx @@ -2,224 +2,148 @@ title: 'Use Supabase with Refine' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Refine app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a Refine app - <$Partial path="quickstart_db_setup.mdx" /> +Create a [Refine](https://github.com/refinedev/refine) app using the [create refine-app](https://refine.dev/docs/getting-started/quickstart/). - +The `refine-supabase` preset adds `@refinedev/supabase` supplementary package that supports Supabase in a Refine app. `@refinedev/supabase` out-of-the-box includes the Supabase dependency: [supabase-js](https://github.com/supabase/supabase-js). - +```bash +npm create refine-app@latest -- --preset refine-supabase my-app +``` - +![Refine welcome page](/docs/img/refine-qs-welcome-page.png) - Create a [Refine](https://github.com/refinedev/refine) app using the [create refine-app](https://refine.dev/docs/getting-started/quickstart/). +<$Partial path="api_settings.mdx" variables={{ "framework": "refine", "tab": "frameworks" }} /> - The `refine-supabase` preset adds `@refinedev/supabase` supplementary package that supports Supabase in a Refine app. `@refinedev/supabase` out-of-the-box includes the Supabase dependency: [supabase-js](https://github.com/supabase/supabase-js). +## 4. Update `supabaseClient` with environment variables - +Update the `supabaseClient` with the `SUPABASE_URL` and `SUPABASE_KEY` of your Supabase API, which you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=refine&tab=frameworks). The `supabaseClient` is used in auth provider and data provider methods that allow the Refine app to connect to your Supabase backend. + +```ts name=src/utility/supabaseClient.ts +import { createClient } from '@refinedev/supabase' + +const SUPABASE_URL = '' +const SUPABASE_KEY = '' + +export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, { + db: { + schema: 'public', + }, + auth: { + persistSession: true, + }, +}) +``` + +## 5. Add instruments resource and pages + +Use the following code to automatically add resources and generate code for the pages to show the `instruments` data using Refine Inferencer. + +This defines pages for `list`, `create`, `show` and `edit` actions inside the `src/pages/instruments/` directory with a `` component. - +The `` component depends on `@refinedev/react-table` and `@refinedev/react-hook-form` packages. To avoid errors, you should install them as dependencies with `npm install @refinedev/react-table @refinedev/react-hook-form`. - ```bash name=Terminal - npm create refine-app@latest -- --preset refine-supabase my-app - ``` + - +The `` is a Refine Inferencer component that automatically generates necessary code for the `list`, `create`, `show` and `edit` pages. - +Read more on [how the Inferencer works is in the Refine docs](https://refine.dev/docs/packages/documentation/inferencer/). - - +
- You will develop your app, connect to the Supabase backend and run the Refine app in VS Code. +```bash +npm run refine create-resource instruments +``` - +## 6. Add routes for instruments pages - +Add routes for the `list`, `create`, `show`, and `edit` pages. - ```bash name=Terminal - cd my-app - code . - ``` + - +Remove the `index` route for the Welcome page presented with the `` component. - + - - +```tsx name=src/App.tsx +import { Refine } from '@refinedev/core' +import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar' +import routerProvider, { + DocumentTitleHandler, + NavigateToResource, + UnsavedChangesNotifier, +} from '@refinedev/react-router' +import { dataProvider, liveProvider } from '@refinedev/supabase' +import { BrowserRouter, Route, Routes } from 'react-router-dom' - Start the app, go to http://localhost:5173 in a browser, and you should be greeted with the Refine Welcome page. +import './App.css' - +import authProvider from './authProvider' +import { + InstrumentsCreate, + InstrumentsEdit, + InstrumentsList, + InstrumentsShow, +} from './pages/instruments' +import { supabaseClient } from './utility' - - - ```bash name=Terminal - npm run dev - ``` - - - ![Refine welcome page](/docs/img/refine-qs-welcome-page.png) - - - - - - - - - - You now have to update the `supabaseClient` with the `SUPABASE_URL` and `SUPABASE_KEY` of your Supabase API. The `supabaseClient` is used in auth provider and data provider methods that allow the Refine app to connect to your Supabase backend. - - - - - - - - - - ```ts name=src/utility/supabaseClient.ts - import { createClient } from "@refinedev/supabase"; - - const SUPABASE_URL = YOUR_SUPABASE_URL; - const SUPABASE_KEY = YOUR_SUPABASE_KEY - - export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, { - db: { - schema: "public", - }, - auth: { - persistSession: true, - }, - }); - ``` - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "refine", "tab": "frameworks" }} /> - - - - - - - - - You have to then configure resources and define pages for `instruments` resource. - - Use the following command to automatically add resources and generate code for pages for `instruments` using Refine Inferencer. - - This defines pages for `list`, `create`, `show` and `edit` actions inside the `src/pages/instruments/` directory with `` component. - - The `` component depends on `@refinedev/react-table` and `@refinedev/react-hook-form` packages. In order to avoid errors, you should install them as dependencies with `npm install @refinedev/react-table @refinedev/react-hook-form`. - - - - The `` is a Refine Inferencer component that automatically generates necessary code for the `list`, `create`, `show` and `edit` pages. - - More on [how the Inferencer works is available in the docs here](https://refine.dev/docs/packages/documentation/inferencer/). - - - - - - - - - ```bash name=Terminal - npm run refine create-resource instruments - ``` - - - - - - - - Add routes for the `list`, `create`, `show`, and `edit` pages. - - - - You should remove the `index` route for the Welcome page presented with the `` component. - - - - - - - ```tsx name=src/App.tsx - import { Refine } from "@refinedev/core"; - import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; - import routerProvider, { - DocumentTitleHandler, - NavigateToResource, - UnsavedChangesNotifier, - } from "@refinedev/react-router"; - import { dataProvider, liveProvider } from "@refinedev/supabase"; - import { BrowserRouter, Route, Routes } from "react-router-dom"; - - import "./App.css"; - import authProvider from "./authProvider"; - import { supabaseClient } from "./utility"; - import { InstrumentsCreate, InstrumentsEdit, InstrumentsList, InstrumentsShow } from "./pages/instruments"; - - function App() { - return ( - - - - - } - /> - - } /> - } /> - } /> - } /> - - - - - - - - - ); - } - - export default App; - ``` - - - - - - - - Now you should be able to see the instruments pages along the `/instruments` routes. You may now edit and add new instruments using the Inferencer generated UI. - - The Inferencer auto-generated code gives you a good starting point on which to keep building your `list`, `create`, `show` and `edit` pages. They can be obtained by clicking the `Show the auto-generated code` buttons in their respective pages. - - - - +function App() { + return ( + + + + + } /> + + } /> + } /> + } /> + } /> + + + + + + + + + ) +} + +export default App +``` + +## 7. View instruments pages + +Start the app with the following command: + +```bash +npm run dev +``` + +Open http://localhost:5173/instruments in a browser, and you should be able to see the instruments pages along the `/instruments` routes. You can edit and add new instruments using the Inferencer generated UI. + +The Inferencer auto-generated code gives you a good starting point on which to keep building your `list`, `create`, `show` and `edit` pages. You can get these by clicking the `Show the auto-generated code` buttons in their respective pages. diff --git a/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx b/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx index bec7d960cfd..bbf867e3aad 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/ruby-on-rails.mdx @@ -2,116 +2,68 @@ title: 'Use Supabase with Ruby on Rails' subtitle: 'Learn how to create a Rails project and connect it to your Supabase Postgres database.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +## 1. Create a Rails project - - +With your Ruby and Rails versions up to date, run `rails new` on your terminal to scaffold a new project. - Make sure your Ruby and Rails versions are up to date, then use `rails new` to scaffold a new Rails project. Use the `-d=postgresql` flag to set it up for Postgres. +Use the `-d=postgresql` flag to set it up for Postgres. - Go to the [Rails docs](https://guides.rubyonrails.org/getting_started.html) for more details. +Check the [Rails docs](https://guides.rubyonrails.org/getting_started.html) for more details. - +```bash +rails new blog -d=postgresql +``` - +## 2. Set up the Postgres connection details - ```bash name=Terminal - rails new blog -d=postgresql - ``` +Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely. - +When your project is up and running, navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&method=session). - +Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password. You can reset your database password in your [Database Settings](/dashboard/project/_/database/settings) if you do not have it. - - + - Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely. +If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode. - When your project is up and running, navigate to your project dashboard and click on [Connect](/dashboard/project/_?showConnect=true&method=session). + - Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password. You can reset your database password in your [Database Settings](/dashboard/project/_/database/settings) if you do not have it. +```bash name=.env +export DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres +``` - +## 3. Create and run a database migration - If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode. +Rails includes Active Record as the ORM as well as database migration tooling which generates the SQL migration files for you. - +Create an example `Article` model and generate the migration files. - +```bash +bin/rails generate model Article title:string body:text +bin/rails db:migrate +``` - +## 4. Use the model to interact with the database - ```bash name=Terminal - export DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres - ``` +You can use the included Rails console to interact with the database. For example, you can create new entries or list all entries in a Model's table. - +```bash +bin/rails console +``` - +```rb name=irb +article = Article.new(title: "Hello Rails", body: "I am on Rails!") +article.save # Saves the entry to the database - - +Article.all +``` - Rails includes Active Record as the ORM as well as database migration tooling which generates the SQL migration files for you. +## 5. Start the app - Create an example `Article` model and generate the migration files. +Run the development server. Go to http://127.0.0.1:3000 in a browser to see your application running. - - - - - ```bash name=Terminal - bin/rails generate model Article title:string body:text - bin/rails db:migrate - ``` - - - - - - - - - You can use the included Rails console to interact with the database. For example, you can create new entries or list all entries in a Model's table. - - - - - - ```bash name=Terminal - bin/rails console - ``` - - ```rb name=irb - article = Article.new(title: "Hello Rails", body: "I am on Rails!") - article.save # Saves the entry to the database - - Article.all - ``` - - - - - - - - - Run the development server. Go to http://127.0.0.1:3000 in a browser to see your application running. - - - - - - ```bash name=Terminal - bin/rails server - ``` - - - - - - +```bash +bin/rails server +``` diff --git a/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx index 1d5d01fbda5..7b87a50307b 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/solidjs.mdx @@ -2,135 +2,80 @@ title: 'Use Supabase with SolidJS' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a SolidJS app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a SolidJS app - <$Partial path="quickstart_db_setup.mdx" /> +Create a SolidJS app using the `degit` command. - +```bash +npx degit solidjs/templates/js my-app +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a SolidJS app. - Create a SolidJS app using the `degit` command. +Navigate to the SolidJS app and install `supabase-js`. - +```bash +cd my-app && npm install @supabase/supabase-js +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "solidjs", "tab": "frameworks" }} /> - ```bash name=Terminal - npx degit solidjs/templates/js my-app - ``` +## 5. Declare Supabase environment variables - +Create a `.env.local` file and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=solidjs&tab=frameworks): - +<$CodeTabs> - - +```text name=.env.local +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` - The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a SolidJS app. + - Navigate to the SolidJS app and install `supabase-js`. +## 6. Query data from the app - +In `App.jsx`, create a Supabase client to fetch the instruments data. - +Add a `getInstruments` function to fetch the data and display the query result to the page. - ```bash name=Terminal - cd my-app && npm install @supabase/supabase-js - ``` +```jsx name=src/App.jsx +import { createClient } from '@supabase/supabase-js' +import { createResource, For } from 'solid-js' - +const supabase = createClient( + import.meta.env.VITE_SUPABASE_URL, + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY +) - +async function getInstruments() { + const { data } = await supabase.from('instruments').select() + return data +} - - +function App() { + const [instruments] = createResource(getInstruments) - Create a `.env.local` file and populate with your Supabase connection variables: + return ( +
    + {(instrument) =>
  • {instrument.name}
  • }
    +
+ ) +} - - +export default App +``` +## 7. Start the app -
+Start the app and go to http://localhost:3000 in a browser and you should see the list of instruments. - - - <$CodeTabs> - - ```text name=.env.local - VITE_SUPABASE_URL= - VITE_SUPABASE_PUBLISHABLE_KEY= - ``` - - - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "solidjs", "tab": "frameworks" }} /> - - - -
- - - - - In `App.jsx`, create a Supabase client to fetch the instruments data. - - Add a `getInstruments` function to fetch the data and display the query result to the page. - - - - - - ```jsx name=src/App.jsx - import { createClient } from "@supabase/supabase-js"; - import { createResource, For } from "solid-js"; - - const supabase = createClient('https://.supabase.co', ''); - - async function getInstruments() { - const { data } = await supabase.from("instruments").select(); - return data; - } - - function App() { - const [instruments] = createResource(getInstruments); - - return ( -
    - {(instrument) =>
  • {instrument.name}
  • }
    -
- ); - } - - export default App; - ``` - -
- -
- - - - - Start the app and go to http://localhost:3000 in a browser and you should see the list of instruments. - - - - - - ```bash name=Terminal - npm run dev - ``` - - - - -
+```bash +npm run dev +``` diff --git a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx index 30be6126edc..6c5c08cc444 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/sveltekit.mdx @@ -2,204 +2,131 @@ title: 'Use Supabase with SvelteKit' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a SvelteKit app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a SvelteKit app - <$Partial path="quickstart_db_setup.mdx" /> +Create a SvelteKit app using the `npm create` command. - +```bash +npx sv create my-app +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a SvelteKit app. - Create a SvelteKit app using the `npm create` command. +Navigate to the SvelteKit app and install `supabase-js`. - +```bash +cd my-app && npm install @supabase/supabase-js +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "sveltekit", "tab": "frameworks" }} /> - ```bash name=Terminal - npx sv create my-app - ``` +## 5. Declare Supabase environment variables - +Create a `.env` file at the root of your project and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=sveltekit&tab=frameworks): - +<$CodeTabs> - - +```text name=.env +PUBLIC_SUPABASE_URL= +PUBLIC_SUPABASE_PUBLISHABLE_KEY= +``` - The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a SvelteKit app. + - Navigate to the SvelteKit app and install `supabase-js`. +## 6. Create the Supabase client - +Create a `src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client: - +<$CodeTabs> - ```bash name=Terminal - cd my-app && npm install @supabase/supabase-js - ``` +```js name=src/lib/supabaseClient.js +import { createClient } from '@supabase/supabase-js' +import { PUBLIC_SUPABASE_PUBLISHABLE_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public' - +export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY) +``` - +```ts name=src/lib/supabaseClient.ts +import { createClient } from '@supabase/supabase-js' +import { PUBLIC_SUPABASE_PUBLISHABLE_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public' - +export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY) +``` - + - Create a `.env` file at the root of your project and populate with your Supabase connection variables: +## 7. Query data from the app - - +Use `load` method to fetch the data server-side and display the query results as a list. +Create `+page.server.js` file in the `src/routes` directory with the following code. - +<$CodeTabs> - +```js name=src/routes/+page.server.js +import { supabase } from '$lib/supabaseClient' - <$CodeTabs> +export async function load() { + const { data } = await supabase.from('instruments').select() + return { + instruments: data ?? [], + } +} +``` - ```text name=.env - PUBLIC_SUPABASE_URL= - PUBLIC_SUPABASE_PUBLISHABLE_KEY= - ``` +```ts name=src/routes/+page.server.ts +import { supabase } from '$lib/supabaseClient' - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "sveltekit", "tab": "frameworks" }} /> +import type { PageServerLoad } from './$types' - +type Instrument = { + id: number + name: string +} - +export const load: PageServerLoad = async () => { + const { data, error } = await supabase.from('instruments').select<'instruments', Instrument>() - - + if (error) { + console.error('Error loading instruments:', error.message) + return { instruments: [] } + } - Create a `src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client: + return { + instruments: data ?? [], + } +} +``` - + - +Replace the existing content in your `+page.svelte` file in the `src/routes` directory with the following code. - <$CodeTabs> +```svelte name=src/routes/+page.svelte + - ```js name=src/lib/supabaseClient.js - import { createClient } from '@supabase/supabase-js'; - import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; +
    + {#each data.instruments as instrument} +
  • {instrument.name}
  • + {/each} +
+``` - export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY) - ``` +## 8. Start the app - ```ts name=src/lib/supabaseClient.ts - import { createClient } from '@supabase/supabase-js'; - import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; +Start the app and go to http://localhost:5173 in a browser and you should see the list of instruments. - export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY) - ``` - - - -
- -
- - - - - Use `load` method to fetch the data server-side and display the query results as a list. - - Create `+page.server.js` file in the `src/routes` directory with the following code. - - - - - <$CodeTabs> - - ```js name=src/routes/+page.server.js - import { supabase } from "$lib/supabaseClient"; - - export async function load() { - const { data } = await supabase.from("instruments").select(); - return { - instruments: data ?? [], - }; - } - ``` - - ```ts name=src/routes/+page.server.ts - import type { PageServerLoad } from './$types'; - import { supabase } from '$lib/supabaseClient'; - - type Instrument = { - id: number; - name: string; - }; - - export const load: PageServerLoad = async () => { - const { data, error } = await supabase.from('instruments').select<'instruments', Instrument>(); - - if (error) { - console.error('Error loading instruments:', error.message); - return { instruments: [] }; - } - - return { - instruments: data ?? [], - }; - }; - ``` - - - - - - - - Replace the existing content in your `+page.svelte` file in the `src/routes` directory with the following code. - - - - - - ```svelte name=src/routes/+page.svelte - - -
    - {#each data.instruments as instrument} -
  • {instrument.name}
  • - {/each} -
- ``` - -
- -
- - - - - Start the app and go to http://localhost:5173 in a browser and you should see the list of instruments. - - - - - - ```bash name=Terminal - npm run dev - ``` - - - - -
+```bash +npm run dev +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx index 569ac3591ae..cd9dc019fa6 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx @@ -2,160 +2,95 @@ title: 'Use Supabase with TanStack Start' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a TanStack Start app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a TanStack Start app - <$Partial path="quickstart_db_setup.mdx" /> +Create a TanStack Start app using the official CLI. - +```bash +npm create @tanstack/start@latest my-app -- --package-manager npm --toolchain biome +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a TanStack Start app. - - Create a TanStack Start app using the official CLI. +Navigate to the TanStack Start app and install `supabase-js`. - <$Partial path="uiLibCta.mdx" /> +```bash +cd my-app && npm install @supabase/supabase-js +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> - +## 5. Declare Supabase environment variables - ```bash name=Terminal - npm create @tanstack/start@latest my-app -- --package-manager npm --toolchain biome - ``` +Create a `.env` file in the root of your project and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true): - +<$CodeTabs> - +```text name=.env +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` - - + - The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a TanStack Start app. +## 6. Create a Supabase client utility - Navigate to the TanStack Start app and install `supabase-js`. +Create a new file at `src/utils/supabase.ts` to initialize the Supabase client. - +```ts name=src/utils/supabase.ts +import { createClient } from '@supabase/supabase-js' - +export const supabase = createClient( + import.meta.env.VITE_SUPABASE_URL, + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY +) +``` - ```bash name=Terminal - cd my-app && npm install @supabase/supabase-js - ``` +## 7. Query data from the app - +<$Partial path="uiLibCta.mdx" /> - +Replace the contents of `src/routes/index.tsx` with the following code to add a loader function that fetches the instruments data and displays it on the page. - - +```tsx name=src/routes/index.tsx +import { createFileRoute } from '@tanstack/react-router' - Create a `.env` file in the root of your project and populate with your Supabase connection variables: +import { supabase } from '../utils/supabase' - - +export const Route = createFileRoute('/')({ + loader: async () => { + const { data: instruments } = await supabase.from('instruments').select() + return { instruments } + }, + component: Home, +}) +function Home() { + const { instruments } = Route.useLoaderData() - + return ( +
    + {instruments?.map((instrument) => ( +
  • {instrument.name}
  • + ))} +
+ ) +} +``` - +## 8. Start the app - <$CodeTabs> +Run the development server, go to http://localhost:3000 in a browser and you should see the list of instruments. - ```text name=.env - VITE_SUPABASE_URL= - VITE_SUPABASE_PUBLISHABLE_KEY= - ``` - - - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "", "tab": "" }} /> - - - -
- - - - - Create a new file at `src/utils/supabase.ts` to initialize the Supabase client. - - - - - - ```ts name=src/utils/supabase.ts - import { createClient } from "@supabase/supabase-js"; - - export const supabase = createClient( - import.meta.env.VITE_SUPABASE_URL, - import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY - ); - ``` - - - - - - - - - Replace the contents of `src/routes/index.tsx` with the following code to add a loader function that fetches the instruments data and displays it on the page. - - - - - ```tsx name=src/routes/index.tsx - import { createFileRoute } from '@tanstack/react-router' - import { supabase } from '../utils/supabase' - - export const Route = createFileRoute('/')({ - loader: async () => { - const { data: instruments } = await supabase.from('instruments').select() - return { instruments } - }, - component: Home, - }) - - function Home() { - const { instruments } = Route.useLoaderData() - - return ( -
    - {instruments?.map((instrument) => ( -
  • {instrument.name}
  • - ))} -
- ) - } - ``` - -
- -
- - - - - Run the development server, go to http://localhost:3000 in a browser and you should see the list of instruments. - - - - - - ```bash name=Terminal - npm run dev - ``` - - - - -
+```bash +npm run dev +``` ## Next steps diff --git a/apps/docs/content/guides/getting-started/quickstarts/vue.mdx b/apps/docs/content/guides/getting-started/quickstarts/vue.mdx index 269233acf16..30ea51f9a01 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/vue.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/vue.mdx @@ -2,156 +2,89 @@ title: 'Use Supabase with Vue' subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Vue app.' breadcrumb: 'Framework Quickstarts' -hideToc: true --- - +<$Partial path="quickstart_db_setup.mdx" /> - +## 3. Create a Vue app - <$Partial path="quickstart_db_setup.mdx" /> +Create a Vue app using the `npm init` command. - +```sh +npm init vue@latest my-app +``` - +## 4. Install the Supabase client library - +The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a Vue app. - - Create a Vue app using the `npm init` command. +Navigate to the Vue app and install `supabase-js`. - <$Partial path="uiLibCta.mdx" /> +```bash +cd my-app && npm install @supabase/supabase-js +``` - +<$Partial path="api_settings.mdx" variables={{ "framework": "vue", "tab": "frameworks" }} /> - +## 5. Declare Supabase environment variables - ```sh name=Terminal - npm init vue@latest my-app - ``` +Create a `.env.local` file and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=vue&tab=frameworks): - +<$CodeTabs> - +```text name=.env.local +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` - - + - The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a Vue app. +## 6. Create the Supabase client - Navigate to the Vue app and install `supabase-js`. +Create a `/src/lib` directory in your Vue app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client: - +```js name=src/lib/supabaseClient.js +import { createClient } from '@supabase/supabase-js' - +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY - ```bash name=Terminal - cd my-app && npm install @supabase/supabase-js - ``` +export const supabase = createClient(supabaseUrl, supabasePublishableKey) +``` - +## 7. Query data from the app - +Replace the existing content in your `App.vue` file with the following code. - - +```vue name=src/App.vue + - + +``` - <$CodeTabs> +## 8. Start the app - ```text name=.env.local - VITE_SUPABASE_URL= - VITE_SUPABASE_PUBLISHABLE_KEY= - ``` +Start the app and go to http://localhost:5173 in a browser and you should see the list of instruments. - - <$Partial path="api_settings_steps.mdx" variables={{ "framework": "vue", "tab": "frameworks" }} /> - - - - - - - - - Create a `/src/lib` directory in your Vue app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client: - - - - - - ```js name=src/lib/supabaseClient.js - import { createClient } from '@supabase/supabase-js' - - const supabaseUrl = import.meta.env.VITE_SUPABASE_URL - const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY - - export const supabase = createClient(supabaseUrl, supabasePublishableKey) - ``` - - - - - - - - - Replace the existing content in your `App.vue` file with the following code. - - - - - - ```vue name=src/App.vue - - - - ``` - - - - - - - - - Start the app and go to http://localhost:5173 in a browser and you should see the list of instruments. - - - - - - ```bash name=Terminal - npm run dev - ``` - - - - - +```bash +npm run dev +``` diff --git a/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx b/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx index 9f9d8b943f5..19ea149237c 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx @@ -43,7 +43,7 @@ Run `flutter pub get` to install the dependencies. With dependencies installed, set up deep links. Setting up deep links is required to bring back the user to the app when they click on the magic link to sign in. -We can setup deep links with just a minor tweak on our Flutter application. +We can setup deep links with a minor tweak on our Flutter application. We have to use `io.supabase.flutterquickstart` as the scheme. In this example, we will use `login-callback` as the host for our deep link, but you can change it to whatever you would like. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-ionic-vue.mdx b/apps/docs/content/guides/getting-started/tutorials/with-ionic-vue.mdx index baff725b244..fcf2908a826 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-ionic-vue.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-ionic-vue.mdx @@ -78,6 +78,8 @@ meta="name=src/views/Account.vue" With all the components in place, update `App.vue` and the app routes: +<$Partial path="auth_methods.mdx" /> + <$CodeSample path="/user-management/ionic-vue-user-management/src/router/index.ts" lines={[[1, -1]]} diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index f7fd26e7894..bc3467a8f54 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -53,7 +53,7 @@ You can find the full contents of this file [in the example repository](https:// Next.js is a versatile framework offering pre-rendering at build time (SSG), server-side rendering at request time (SSR), API routes, and proxy edge-functions. -To better integrate with the framework, we've created the `@supabase/ssr` package for Server-Side Auth. It has all the functionalities to quickly configure your Supabase project to use cookies for storing user sessions. Read the [Next.js Server-Side Auth guide](/docs/guides/auth/server-side/creating-a-client?queryGroups=package-manager&package-manager=npm&queryGroups=framework&framework=nextjs) for more information. +To better integrate with the framework, we've created the `@supabase/ssr` package for Server-Side Auth. It has all the functionalities to configure your Supabase project to use cookies for storing user sessions. Read the [Next.js Server-Side Auth guide](/docs/guides/auth/server-side/creating-a-client?queryGroups=package-manager&package-manager=npm&queryGroups=framework&framework=nextjs) for more information. Install the package for Next.js. @@ -216,7 +216,7 @@ lines={[[1, 4], [7, 78], [88, 89], [99, -1]]} meta="name=app/account/account-form.tsx" /> -Create an account page for the `AccountForm` component you just created +Create an account page for the `AccountForm` component you created <$CodeSample path="/user-management/nextjs-user-management/app/account/page.tsx" diff --git a/apps/docs/content/guides/getting-started/tutorials/with-redwoodjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-redwoodjs.mdx index 7ff85870f22..cb0774b4cd2 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-redwoodjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-redwoodjs.mdx @@ -115,7 +115,7 @@ SUPABASE_JWT_SECRET=YOUR_SUPABASE_JWT_SECRET -And finally, you will also need to save **just** the `web side` environment variables to the `redwood.toml`. +And finally, you will also need to save **only** the `web side` environment variables to the `redwood.toml`. <$CodeTabs> @@ -207,7 +207,7 @@ The `/` is important here as it creates a root level route. -You can stop the `dev` server if you want; to see your changes, just be sure to run `yarn rw dev` again. +You can stop the `dev` server if you want; to see your changes, run `yarn rw dev` again. You should see the `Home` page route in `web/src/Routes.js`: diff --git a/apps/docs/content/guides/integrations/partner-integration-guide.mdx b/apps/docs/content/guides/integrations/partner-integration-guide.mdx index 3cf4bd56726..6df0fa75f51 100644 --- a/apps/docs/content/guides/integrations/partner-integration-guide.mdx +++ b/apps/docs/content/guides/integrations/partner-integration-guide.mdx @@ -55,6 +55,8 @@ sequenceDiagram Browser->>User: Display "Integration Installed" Message ``` +The user clicks **Install Integration** in the Supabase Dashboard, which routes them through the partner's click-handler page and on to the Supabase authorization page. Supabase shows a consent screen. Once the user consents, Supabase redirects back to the partner with an authorization code. The partner exchanges that code for a token, uses the token to request Management API resources, and finally shows the user an "Integration Installed" message. + ### Step 1: Implement the redirect endpoint Expose a `GET` endpoint at any URL you control: @@ -136,6 +138,8 @@ sequenceDiagram Browser->>User: Display "Integration Installed" Message ``` +Walking through the sequence: when the user clicks **Install Integration**, Supabase sends the partner a signed JWT and asks it to generate a redirect URL. The partner validates the JWT, generates a unique redirect record and URL, and returns it. Supabase redirects the user to that URL; the partner validates the redirect record, optionally has the user complete extra steps, and then sends them to the Supabase authorization page. From there the flow matches Method 1: the user consents, Supabase returns an authorization code, the partner exchanges it for a token, requests Management API resources, and the integration completes. + ### Step 1: Exchange public keys with Supabase Supabase generates two key-pairs, one for staging, one for production, and shares the public keys with you. Save both public keys and their key IDs in your system. The keys are PEM-encoded EC P-256. diff --git a/apps/docs/content/guides/integrations/vercel-marketplace.mdx b/apps/docs/content/guides/integrations/vercel-marketplace.mdx index 35c554d1695..ac3c21bbf61 100644 --- a/apps/docs/content/guides/integrations/vercel-marketplace.mdx +++ b/apps/docs/content/guides/integrations/vercel-marketplace.mdx @@ -8,7 +8,7 @@ description: 'Manage your Supabase projects directly through Vercel' The Vercel Marketplace is a feature that allows you to manage third-party resources, such as Supabase, directly from the Vercel platform. This integration offers a seamless experience with unified billing, streamlined authentication, and easy access management for your team. -When you create an organization and projects through Vercel Marketplace, they function just like those created directly within Supabase. However, the billing is handled through your Vercel account, and you can manage your resources directly from the Vercel dashboard or CLI. Additionally, environment variables are automatically synchronized, making them immediately available for your connected projects. +When you create an organization and projects through Vercel Marketplace, they function like those created directly within Supabase. However, the billing is handled through your Vercel account, and you can manage your resources directly from the Vercel dashboard or CLI. Additionally, environment variables are automatically synchronized, making them immediately available for your connected projects. For more information, see [Introducing the Vercel Marketplace](https://vercel.com/blog/introducing-the-vercel-marketplace) blog post. diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index 8b531f49e4b..c6005edb3c6 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -4,7 +4,7 @@ description: 'The Supabase CLI provides tools to develop your project locally, d subtitle: 'Develop locally, deploy to the Supabase Platform, and set up CI/CD workflows' --- -The Supabase CLI enables you to run the entire Supabase stack locally, on your machine or in a CI environment. With just two commands, you can set up and start a new local project: +The Supabase CLI enables you to run the entire Supabase stack locally, on your machine or in a CI environment. With two commands, you can set up and start a new local project: 1. `supabase init` to create a new local project 2. `supabase start` to launch the Supabase services diff --git a/apps/docs/content/guides/local-development/customizing-email-templates.mdx b/apps/docs/content/guides/local-development/customizing-email-templates.mdx index 7316d957249..d0ad4d727b9 100644 --- a/apps/docs/content/guides/local-development/customizing-email-templates.mdx +++ b/apps/docs/content/guides/local-development/customizing-email-templates.mdx @@ -6,6 +6,8 @@ subtitle: 'Customize local email templates via the config file.' You can customize the email templates for local development by [editing the `config.toml` file](/docs/guides/local-development/cli/config#auth-config). +This guide covers local development and CLI workflows. For hosted projects, use the [Email Templates](/dashboard/project/_/auth/templates) page in the dashboard. See [Email templates](/docs/guides/auth/auth-email-templates) for terminology, limitations, and customization patterns that apply in every environment. + For configuring a self-hosted Supabase instance, see [Custom Email Templates](/docs/guides/self-hosting/custom-email-templates) @@ -104,7 +106,7 @@ There are several authentication-related email templates which can be configured **Default subject**: "`{{ .Token }} is your verification code`" **When sent**: When a user needs to re-authenticate for sensitive operations **Purpose**: Ask users to verify their identity before a sensitive operation -**Content**: Contains a 6-digit OTP code for verification +**Content**: Contains a 8-digit OTP code for verification ## Available security notification email templates @@ -179,7 +181,7 @@ https://project-ref.supabase.co/auth/v1/verify?token={{ .TokenHash }}&type=email ### `Token` -Contains a 6-digit One-Time-Password (OTP) that can be used instead of the `ConfirmationURL`. +Contains a 8-digit One-Time-Password (OTP) that can be used instead of the `ConfirmationURL`. **Usage** diff --git a/apps/docs/content/guides/local-development/overview.mdx b/apps/docs/content/guides/local-development/overview.mdx index 708a4d065c0..0c2b6f00169 100644 --- a/apps/docs/content/guides/local-development/overview.mdx +++ b/apps/docs/content/guides/local-development/overview.mdx @@ -7,7 +7,7 @@ video: 'https://www.youtube-nocookie.com/v/vyHyYpvjaks' tocVideo: 'vyHyYpvjaks' --- -Supabase is a flexible platform that lets you decide how you want to build your projects. You can use the Dashboard directly to get up and running quickly, or use a proper local setup. We suggest you work locally and deploy your changes to a linked project on the [Supabase Platform](https://app.supabase.io/). +Supabase is a flexible platform that lets you decide how you want to build your projects. You can use the Dashboard directly to get up and running, or use a proper local setup. We suggest you work locally and deploy your changes to a linked project on the [Supabase Platform](https://app.supabase.io/). Develop locally using the CLI to run a local Supabase stack. You can use the integrated Studio Dashboard to make changes, then capture your changes in schema migration files, which can be saved in version control. diff --git a/apps/docs/content/guides/local-development/seeding-your-database.mdx b/apps/docs/content/guides/local-development/seeding-your-database.mdx index 4426ea1f7f8..48e55d531a2 100644 --- a/apps/docs/content/guides/local-development/seeding-your-database.mdx +++ b/apps/docs/content/guides/local-development/seeding-your-database.mdx @@ -98,7 +98,31 @@ export default defineConfig({ Suppose you have a database with the following schema: -![An example schema](/docs/img/guides/cli/snaplet-example-schema.png) +```mermaid +erDiagram + User ||--o{ Post : createdBy + User ||--o{ Comment : userId + Post ||--o{ Comment : postId + User { + bigint id PK + text email + text name + } + Post { + bigint id PK + text title + text content + bigint createdBy FK + } + Comment { + bigint id PK + text text + bigint userId FK + bigint postId FK + } +``` + +This example schema has three tables. A `User` can author many `Post` rows (`Post.createdBy` references `User.id`) and many `Comment` rows (`Comment.userId` references `User.id`), and each `Post` can have many `Comment` rows (`Comment.postId` references `Post.id`). In other words, users create posts and comments, and every comment belongs to a post. You can use the seed script example generated by Snaplet `seed.ts` to define the values you want to generate. For example: @@ -107,8 +131,8 @@ You can use the seed script example generated by Snaplet `seed.ts` to define the - Three `Post.comments` from three different users. ```ts seed.ts -import { createSeedClient } from '@snaplet/seed' import { copycat } from '@snaplet/copycat' +import { createSeedClient } from '@snaplet/seed' async function main() { const seed = await createSeedClient({ dryRun: true }) diff --git a/apps/docs/content/guides/platform/custom-domains.mdx b/apps/docs/content/guides/platform/custom-domains.mdx index 66f65eb63fb..d0076126fa8 100644 --- a/apps/docs/content/guides/platform/custom-domains.mdx +++ b/apps/docs/content/guides/platform/custom-domains.mdx @@ -53,7 +53,7 @@ You need to add a CNAME record to your domain's DNS settings to ensure your cust If your project's default domain is `abcdefghijklmnopqrst.supabase.co` you should: - Create a CNAME record for `api.example.com` that resolves to `abcdefghijklmnopqrst.supabase.co.`. -- Use a low TTL value to quickly propagate changes in case you make a mistake. +- Use a low TTL value to propagate changes in case you make a mistake. ### Verify ownership of the domain @@ -73,7 +73,7 @@ Required outstanding validation records: _acme-challenge.api.example.com. TXT -> ca3-F1HvR9i938OgVwpCFwi1jTsbhe1hvT0Ic3efPY3Q ``` -Add the record to your domains' DNS settings. Make sure to trim surrounding whitespace. Use a low TTL value so you can quickly change the records if you make a mistake. +Add the record to your domains' DNS settings. Make sure to trim surrounding whitespace. Use a low TTL value so you can change the records if you make a mistake. Some DNS registrars automatically append your domain name to the DNS entries being created. As such, creating a DNS record for `api.example.com` might instead create a record for `api.example.com.example.com`. In such cases, remove the domain name from the records you're creating; as an example, you would create a TXT record for `api`, instead of `api.example.com`. @@ -168,7 +168,7 @@ Assume your Supabase project's domain is `abcdefghijklmnopqrst.supabase.co` and Use the [`vanity-subdomains check-availability`](/docs/reference/cli/supabase-vanity-subdomains-check-availability) command of the CLI to check if your desired subdomain is available for use: ```bash -supabase vanity-subdomains --project-ref abcdefghijklmnopqrst check-availability --desired-subdomain my-example-brand --experimental +supabase vanity-subdomains check-availability --project-ref abcdefghijklmnopqrst --desired-subdomain my-example-brand --experimental ``` ### Prepare to activate the subdomain @@ -200,7 +200,7 @@ Once you've chosen an available subdomain and have done all the necessary prepar Use the [`vanity-subdomains activate`](/docs/reference/cli/supabase-vanity-subdomains-activate) command to activate and claim your subdomain: ```bash -supabase vanity-subdomains --project-ref abcdefghijklmnopqrst activate --desired-subdomain my-example-brand --experimental +supabase vanity-subdomains activate --project-ref abcdefghijklmnopqrst --desired-subdomain my-example-brand --experimental ``` If you wish to use the new domain in client code, you can set it up like so: diff --git a/apps/docs/content/guides/platform/free-project-pausing.mdx b/apps/docs/content/guides/platform/free-project-pausing.mdx new file mode 100644 index 00000000000..9e6b2fc6bbf --- /dev/null +++ b/apps/docs/content/guides/platform/free-project-pausing.mdx @@ -0,0 +1,45 @@ +--- +title: 'Project Pausing' +description: 'Free project pausing behavior.' +--- + +{/* supa-mdx-lint-disable-next-line Rule003Spelling */} +Supabase pauses Free Plan projects that show low activity over a 7-day period to save server resources. This guide explains how pausing works, how to restore a paused project, and how to avoid pausing altogether. + + + +Projects under a paid plan cannot be paused and are not subject to automatic pausing for inactivity. To pause a project currently under a paid plan, first transfer the project to an organization on the Free plan. + + + +## How automatic pausing works + +A Free plan project is considered inactive if it does not receive sufficient user database activity over the past week. Projects with too few user queries during that window are the clearest candidates for pausing. While you may be actively using the project, it's possible that usage is not enough to exclude it from automatic pausing. Typically a few user requests to the database each day over the previous week is enough to keep the project from being paused. + +Supabase sends two emails to the project owner regarding project pausing: + +1. A warning email roughly one week before the pause takes effect. +2. A confirmation email once the project has been paused. + +After receiving an initial email warning, the pause can be prevented by taking one of the following steps: + +- Visit the project from the [Supabase Dashboard](/dashboard/project/_) to generate activity. +- Generate a sufficient amount of activity by making API calls to your project or sending requests via your connected application. + +## Restoring a paused project + +You can restore a paused project for up to 90 days after it was paused: + +1. Open the [Supabase Dashboard](/dashboard/organizations) +2. Select the organization, followed by the paused project +3. Click **Resume project** and confirm + +The project will return to its previous state, including data and configurations. + +### 90-day window to restore + +Once the project is paused, there is a 90-day window to restore the project on the platform from within Supabase Studio. The 90-day window allows Supabase to introduce platform changes that may not be backward compatible with older backups. Unlike active projects, static backups can't be updated to accommodate such changes. + +## Preventing automatic project pausing + +To prevent future automatic pausing, upgrade to the Pro Plan from [Billing Settings](/dashboard/org/_/billing?panel=subscriptionPlan). Paid projects cannot be paused and are not subject to pausing for inactivity. diff --git a/apps/docs/content/guides/platform/ipv4-address.mdx b/apps/docs/content/guides/platform/ipv4-address.mdx index 7dc01f9765b..f17be34c3cd 100644 --- a/apps/docs/content/guides/platform/ipv4-address.mdx +++ b/apps/docs/content/guides/platform/ipv4-address.mdx @@ -77,7 +77,7 @@ By default, Supabase Postgres use IPv6 addresses. If your system doesn't support ### Checking your network IPv6 support -You can check if your personal network is IPv6 compatible at https://test-ipv6.com. +You can check if your personal network is IPv6 compatible at https://ipv6test.google.com/. ### Checking platforms for IPv6 support: diff --git a/apps/docs/content/guides/platform/manage-your-usage/branching.mdx b/apps/docs/content/guides/platform/manage-your-usage/branching.mdx index 774214977f3..64c6f18a4bd 100644 --- a/apps/docs/content/guides/platform/manage-your-usage/branching.mdx +++ b/apps/docs/content/guides/platform/manage-your-usage/branching.mdx @@ -5,7 +5,7 @@ title: 'Manage Branching usage' ## What you are charged for -Each [Preview branch](/docs/guides/deployment/branching) is a separate environment with all Supabase services (Database, Auth, Storage, etc.). You're charged for usage within that environment—such as [Compute](/docs/guides/platform/manage-your-usage/compute), [Disk Size](/docs/guides/platform/manage-your-usage/disk-size), [Egress](/docs/guides/platform/manage-your-usage/egress), and [Storage](/docs/guides/platform/manage-your-usage/storage-size)—just like the project you branched from. +Each [Preview branch](/docs/guides/deployment/branching) is a separate environment with all Supabase services (Database, Auth, Storage, etc.). You're charged for usage within that environment—such as [Compute](/docs/guides/platform/manage-your-usage/compute), [Disk Size](/docs/guides/platform/manage-your-usage/disk-size), [Egress](/docs/guides/platform/manage-your-usage/egress), and [Storage](/docs/guides/platform/manage-your-usage/storage-size)—the same as the project you branched from. diff --git a/apps/docs/content/guides/platform/manage-your-usage/ipv4.mdx b/apps/docs/content/guides/platform/manage-your-usage/ipv4.mdx index a43266ac178..8c004f63755 100644 --- a/apps/docs/content/guides/platform/manage-your-usage/ipv4.mdx +++ b/apps/docs/content/guides/platform/manage-your-usage/ipv4.mdx @@ -117,4 +117,4 @@ If you remove the IPv4 add-on, you are no longer billed from the time of removal ## Optimize usage -To see whether your database actually needs a dedicated IPv4 address, refer to [When you need the IPv4 add-on](/docs/guides/platform/ipv4-address#when-you-need-the-ipv4-add-on). +To see whether your database needs a dedicated IPv4 address, refer to [When you need the IPv4 add-on](/docs/guides/platform/ipv4-address#when-you-need-the-ipv4-add-on). diff --git a/apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx b/apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx index af40d0e9678..7c26bbc978c 100644 --- a/apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx +++ b/apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx @@ -65,7 +65,7 @@ Every service in your Supabase project automatically generates Logs — you don' - **Reduce log-level verbosity** in your Edge Functions and server-side code (for example, `info` → `warn` in production). - **Audit verbose logging in your application code.** Application-level logs forwarded to Supabase services count toward ingest. -- **Cap log payload size.** Large structured payloads inflate GB-billed volume quickly. +- **Cap log payload size.** Large structured payloads can inflate GB-billed volume. - **Investigate spikes.** Use the [**Logs Explorer**](/dashboard/project/_/logs-explorer) section of the Dashboard to find services or endpoints producing unusually high volume. ## Exceeding Quotas diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/firestore-data.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/firestore-data.mdx index de95b7aee8b..aafd172a0b4 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase/firestore-data.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase/firestore-data.mdx @@ -124,7 +124,7 @@ module.exports = (collectionName, doc, recordCounters, writeRecord) => { - `writeRecord`: This function automatically handles the process of writing data to other JSON files (useful for "flatting" your document into separate JSON files to be written to separate database tables). `writeRecord` takes the following parameters: - `name`: Name of the JSON file to write to. - `doc`: The document to write to the file. - - `recordCounters`: The same `recordCounters` object that was passed to this hook (just passes it on). + - `recordCounters`: The same `recordCounters` object that was passed to this hook (passes it on). ### Examples diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx index f361f224f8d..036416e8746 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase/heroku.mdx @@ -8,7 +8,7 @@ tocVideo: 'xsRhPMphtZ4' Supabase is one of the best [free alternatives to Heroku Postgres](/alternatives/supabase-vs-heroku-postgres). This guide shows how to migrate your Heroku Postgres database to Supabase. This migration requires the [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) and [psql](https://www.postgresql.org/docs/current/app-psql.html) CLI tools, which are installed automatically as part of the complete Postgres installation package. -Alternatively, use the [Heroku to Supabase migration tool](https://migrate.supabase.com/) to migrate in just a few clicks. +Alternatively, use the [Heroku to Supabase migration tool](https://migrate.supabase.com/) to migrate in a few clicks. ## Quick demo diff --git a/apps/docs/content/guides/platform/performance.mdx b/apps/docs/content/guides/platform/performance.mdx index 0c75a8d30fb..b95198471ff 100644 --- a/apps/docs/content/guides/platform/performance.mdx +++ b/apps/docs/content/guides/platform/performance.mdx @@ -31,7 +31,7 @@ In such a scenario, you can consider: You can use the [pg_stat_activity](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW) view to debug which clients are holding open connections on your DB. `pg_stat_activity` only exposes information on direct connections to the database. Information on the number of connections to Supavisor is available [via the metrics endpoint](../telemetry/metrics). -Depending on the clients involved, you might be able to configure them to work with fewer connections (e.g. by imposing a limit on the maximum number of connections they're allowed to use), or shift specific workloads to connect via [Supavisor](/docs/guides/database/connecting-to-postgres#connection-pooler) instead. Transient workflows, which can quickly scale up and down in response to traffic (e.g. serverless functions), can especially benefit from using a connection pooler rather than connecting to the DB directly. +Depending on the clients involved, you might be able to configure them to work with fewer connections (e.g. by imposing a limit on the maximum number of connections they're allowed to use), or shift specific workloads to connect via [Supavisor](/docs/guides/database/connecting-to-postgres#connection-pooler) instead. Transient workflows, which can scale up and down rapidly in response to traffic (e.g. serverless functions), can especially benefit from using a connection pooler rather than connecting to the DB directly. ### Allowing higher number of connections diff --git a/apps/docs/content/guides/platform/postgres-connection-logging.mdx b/apps/docs/content/guides/platform/postgres-connection-logging.mdx index 866336b8046..273cb11a122 100644 --- a/apps/docs/content/guides/platform/postgres-connection-logging.mdx +++ b/apps/docs/content/guides/platform/postgres-connection-logging.mdx @@ -10,6 +10,8 @@ For security monitoring and compliance audits, Postgres can log connection lifec By default, Supabase sets `log_connections` to off for new projects and you must enable it first. This behavior matches common managed Postgres defaults and reduces log volume from high-frequency connection events. +<$Partial path="log_connections_default_effective_date.mdx" /> + Existing projects may retain different settings depending on plan and compliance configuration: - **Team, Enterprise, and HIPAA organizations** — Connection logging is typically enabled to support audit requirements. diff --git a/apps/docs/content/guides/platform/privatelink.mdx b/apps/docs/content/guides/platform/privatelink.mdx index 4e602ed56cc..d3417eb68a5 100644 --- a/apps/docs/content/guides/platform/privatelink.mdx +++ b/apps/docs/content/guides/platform/privatelink.mdx @@ -26,7 +26,7 @@ Supabase PrivateLink is an organisation level configuration. It works by sharing The connection architecture changes from public internet routing to a dedicated private path through AWS's secure network backbone. -Supabase PrivateLink is currently just for direct database and PgBouncer connections only. It does not support other Supabase services like API, Storage, Auth, or Realtime. These services will continue to operate over public internet connections. +Supabase PrivateLink currently supports direct database and PgBouncer connections only. It does not support other Supabase services like API, Storage, Auth, or Realtime. These services will continue to operate over public internet connections. ## Requirements diff --git a/apps/docs/content/guides/platform/read-replicas.mdx b/apps/docs/content/guides/platform/read-replicas.mdx index e0a106e0c3d..c70ecea0044 100644 --- a/apps/docs/content/guides/platform/read-replicas.mdx +++ b/apps/docs/content/guides/platform/read-replicas.mdx @@ -43,12 +43,25 @@ You can only read data from a Read Replica. This is in contrast to a Primary dat id="rr-flow" > - When your database starts slowing down, you face a choice: make your existing database bigger (scale vertically), or spread the load across multiple databases (scale horizontally). Both approaches work. Neither is universally correct. The right answer depends on your workload, your budget, and where the bottleneck actually is. + When your database starts slowing down, you face a choice: make your existing database bigger (scale vertically), or spread the load across multiple databases (scale horizontally). Both approaches work. Neither is universally correct. The right answer depends on your workload, your budget, and where the bottleneck is. - Read Replicas decision flowchart + ```mermaid + flowchart TD + A[Database slowing down] --> B{CPU above 70% sustained?} + B -->|No| C[Monitor, do not scale yet] + B -->|Yes| D{Queries optimized? Indexes in place?} + D -->|No| E[Run EXPLAIN ANALYZE
Add missing indexes
Optimize first] + E --> D + D -->|Yes| F{Workload 80%+ reads?} + F -->|No| G[Upgrade compute
Replicas will not help writes] + F -->|Yes| H{Already at 16XL?} + H -->|Yes| I[Read Replicas
Only horizontal option left] + H -->|No| J{Need workload isolation
or geo-distribution?} + J -->|Yes| K[Read Replicas] + J -->|No| L[Either works
Compute is simpler
Replicas scale further] + ``` + + Supabase recommends not scaling until CPU is sustained above 70%. After it is, confirm your queries are already optimized and properly indexed with `EXPLAIN ANALYZE` before adding hardware. If your workload is less than roughly 80% reads, upgrade compute. Read Replicas only serve reads and won't help writes. If it's read-heavy, Read Replicas become the right choice after you reach the largest compute size of 16XL or earlier if you need workload isolation or geographic distribution. Below 16XL with no isolation need, either option works. While compute is simpler, Read Replicas scale further. diff --git a/apps/docs/content/guides/platform/ssl-enforcement.mdx b/apps/docs/content/guides/platform/ssl-enforcement.mdx index 74249a6da89..2f70acddc3f 100644 --- a/apps/docs/content/guides/platform/ssl-enforcement.mdx +++ b/apps/docs/content/guides/platform/ssl-enforcement.mdx @@ -70,7 +70,7 @@ To get started: You can use the `get` subcommand of the CLI to check whether SSL is currently being enforced: ```bash -supabase ssl-enforcement --project-ref {ref} get --experimental +supabase ssl-enforcement get --project-ref {ref} --experimental ``` Response if SSL is being enforced: @@ -90,13 +90,13 @@ SSL is *NOT* being enforced. The `update` subcommand is used to change the SSL enforcement status for your project: ```bash -supabase ssl-enforcement --project-ref {ref} update --enable-db-ssl-enforcement --experimental +supabase ssl-enforcement update --project-ref {ref} --enable-db-ssl-enforcement --experimental ``` Similarly, to disable SSL enforcement: ```bash -supabase ssl-enforcement --project-ref {ref} update --disable-db-ssl-enforcement --experimental +supabase ssl-enforcement update --project-ref {ref} --disable-db-ssl-enforcement --experimental ``` ### A note about Postgres SSL modes diff --git a/apps/docs/content/guides/platform/sso.mdx b/apps/docs/content/guides/platform/sso.mdx index 9a690a703e7..2349a28d27c 100644 --- a/apps/docs/content/guides/platform/sso.mdx +++ b/apps/docs/content/guides/platform/sso.mdx @@ -74,7 +74,7 @@ Users start their login at supabase.com by entering their email address, then ar - **Login flows** - Choose between IdP-initiated (users start from identity provider), SP-initiated (users start at supabase.com), or both. IdP-initiated is recommended for most organizations and requires no domain configuration. See [Choosing the Right Login Flow](/docs/guides/platform/sso/choosing-login-flow) for guidance. - **Email domains** - Required only if you enable SP-initiated login. You can associate one or more email domains with your SSO provider. Users with matching email addresses can sign in via SSO at supabase.com. Not required for IdP-initiated flow. -- **Auto-join** - Optionally allow users with a matching domain to be added to your organization automatically when they sign in via SSO. Auto-join applies on every login, not just first signup, making it easy to test before enabling. +- **Auto-join** - Optionally allow users with a matching domain to join your organization automatically when they sign in via SSO. This applies on every login, not only on first signup. - **Default role for auto-joined users** - Choose the role (e.g., `Read-only`, `Developer`, `Administrator`, `Owner`) that automatically joined users receive. We recommend using `Developer` as the default (principle of least privilege) and promoting users individually as needed. Refer to [access control](/docs/guides/platform/access-control) for more information about roles. - **Invitation types** - When inviting users to your organization, you can explicitly choose whether the invitation requires SSO authentication or allows non-SSO login (password/social). This enables mixed authentication organizations with both SSO and non-SSO users. diff --git a/apps/docs/content/guides/platform/sso/azure.mdx b/apps/docs/content/guides/platform/sso/azure.mdx index 62d0a7ef3eb..6daa75a6f94 100644 --- a/apps/docs/content/guides/platform/sso/azure.mdx +++ b/apps/docs/content/guides/platform/sso/azure.mdx @@ -64,7 +64,7 @@ First you need to download Supabase's SAML metadata file. Click the button below Alternatively, visit this page to initiate a download: `https://alt.supabase.io/auth/v1/sso/saml/metadata?download=true` -Click on the _Upload metadata file_ option in the toolbar and select the file you just downloaded. +Click on the _Upload metadata file_ option in the toolbar and select the file you downloaded. ![Azure AD console: Supabase application, SAML-based Sign-on screen, selected Upload metadata file button](/docs/img/sso-azure-step-06-1.png) @@ -134,7 +134,7 @@ By default this setting is disabled, users logging in via SSO will not be added ![Auto-join disabled](/docs/img/sso-dashboard-configure-autojoin-disabled.png) -Toggle this on if you want SSO-authenticated users to be **automatically added to your organization** when they log in via SSO. Auto-join applies on **every login**, not just first signup - this makes it safe to test SSO before enabling this feature. +Toggle this on if you want SSO-authenticated users to be **automatically added to your organization** when they log in via SSO. Auto-join applies on **every login**, not only on first signup - this makes it safe to test SSO before enabling this feature. ![Auto-join enable](/docs/img/sso-dashboard-configure-autojoin-enabled.png) diff --git a/apps/docs/content/guides/platform/sso/gsuite.mdx b/apps/docs/content/guides/platform/sso/gsuite.mdx index 4babba24020..608d8f03d0c 100644 --- a/apps/docs/content/guides/platform/sso/gsuite.mdx +++ b/apps/docs/content/guides/platform/sso/gsuite.mdx @@ -144,7 +144,7 @@ By default this setting is disabled, users logging in via SSO will not be added ![Auto-join disabled](/docs/img/sso-dashboard-configure-autojoin-disabled.png) -Toggle this on if you want SSO-authenticated users to be **automatically added to your organization** when they log in via SSO. Auto-join applies on **every login**, not just first signup - this makes it safe to test SSO before enabling this feature. +Toggle this on if you want SSO-authenticated users to be **automatically added to your organization** when they log in via SSO. Auto-join applies on **every login**, not only on first signup - this makes it safe to test SSO before enabling this feature. ![Auto-join enable](/docs/img/sso-dashboard-configure-autojoin-enabled.png) diff --git a/apps/docs/content/guides/platform/sso/okta.mdx b/apps/docs/content/guides/platform/sso/okta.mdx index 370b410872a..b62cd6588ab 100644 --- a/apps/docs/content/guides/platform/sso/okta.mdx +++ b/apps/docs/content/guides/platform/sso/okta.mdx @@ -130,7 +130,7 @@ By default this setting is disabled, users logging in via SSO will not be added ![Auto-join disabled](/docs/img/sso-dashboard-configure-autojoin-disabled.png) -Toggle this on if you want SSO-authenticated users to be **automatically added to your organization** when they log in via SSO. Auto-join applies on **every login**, not just first signup - this makes it safe to test SSO before enabling this feature. +Toggle this on if you want SSO-authenticated users to be **automatically added to your organization** when they log in via SSO. Auto-join applies on **every login**, not only on first signup - this makes it safe to test SSO before enabling this feature. ![Auto-join enable](/docs/img/sso-dashboard-configure-autojoin-enabled.png) diff --git a/apps/docs/content/guides/platform/sso/testing-best-practices.mdx b/apps/docs/content/guides/platform/sso/testing-best-practices.mdx index f9c4a5de033..a85c237a688 100644 --- a/apps/docs/content/guides/platform/sso/testing-best-practices.mdx +++ b/apps/docs/content/guides/platform/sso/testing-best-practices.mdx @@ -180,7 +180,7 @@ Test with 2-3 additional users to verify: -**Recent improvement:** Auto-join now applies on EVERY login, not just first signup. This resolves a common issue where org owners would test with auto-join disabled, enable it, then log in again expecting to auto-join. +**Recent improvement:** Auto-join now applies on EVERY login, not only on first signup. This resolves a common issue where org owners would test with auto-join disabled, enable it, then log in again expecting to auto-join. @@ -253,7 +253,7 @@ Test with 2-3 additional users to verify: - Auto-join works when enabled - Users receive correct default role - Non-matching domains are excluded (if using SP-initiated with domains) -- Existing users auto-join on their next login (not just new signups) +- Existing users auto-join on their next login (not only on new signups) - Auto-join can be disabled and re-enabled as needed - Auto-join is idempotent (no duplicate memberships) - Auto-join works with IdP-initiated only (no domains) @@ -369,7 +369,7 @@ SSO accounts have specific restrictions to prevent accidental organization locko - SSO accounts CAN disable SSO providers - Non-SSO owners CAN delete SSO providers - Error messages clearly explain the restriction -- Restriction applies to all SSO accounts (not just certain roles) +- Restriction applies to all SSO accounts (not only certain roles) ## Common issues and troubleshooting @@ -387,7 +387,7 @@ Based on customer pain points that previously required support intervention: **Solution:** -- Auto-join now applies on **every login**, not just first signup +- Auto-join now applies on **every login**, not only on first signup - To test: Enable auto-join, log out completely, log back in via SSO - If still not working, verify domain configuration matches user email exactly @@ -795,7 +795,7 @@ Before rolling out SSO to your organization: - Auto-join adds users to correct organization (if enabled) - Auto-joined users receive correct default role -- Auto-join works on first login (not just signup) +- Auto-join works on first login (not only on signup) - Existing users auto-join when feature enabled - Auto-join is idempotent (no duplicate memberships) - Auto-join works with IdP-initiated (no domains required) diff --git a/apps/docs/content/guides/platform/temporary-access.mdx b/apps/docs/content/guides/platform/temporary-access.mdx index 32e067c380c..06f76a8bc15 100644 --- a/apps/docs/content/guides/platform/temporary-access.mdx +++ b/apps/docs/content/guides/platform/temporary-access.mdx @@ -97,6 +97,12 @@ curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit" \ ## Using temporary access + + +This feature does not work with IPv6 Transaction pooler (PgBouncer). Direct connections and connections through the IPv4 connection pooler are fully supported. + + + To log in to the database using temporary access, existing connection strings can be used and only the password needs to be changed to the user's API or dashboard token. For example, if a user has been authorized to assume the `postgres` role: @@ -111,8 +117,8 @@ Connecting via the shared connection pooler requires the addition of a new conne ``` # directly in the URI -psql 'postgres://postgres.{project-ref}:sbp_111222333aaabbbccc@aws-1-us-west-1.pooler.supabase.com:5432/postgres?options=-c%20jit%3don' +psql 'postgres://postgres.{project-ref}:sbp_111222333aaabbbccc@aws-1-us-west-1.pooler.supabase.com:5432/postgres?options=-c%20jit%3dtrue' # or as a connection info string -psql "host=aws-1-us-west-1.pooler.supabase.com user=postgres.{project-ref} options='-c jit=on'" +psql "host=aws-1-us-west-1.pooler.supabase.com user=postgres.{project-ref} options='-c jit=true'" ``` diff --git a/apps/docs/content/guides/queues/pgmq.mdx b/apps/docs/content/guides/queues/pgmq.mdx index 6b7f85d8cb5..caca4f8a191 100644 --- a/apps/docs/content/guides/queues/pgmq.mdx +++ b/apps/docs/content/guides/queues/pgmq.mdx @@ -6,7 +6,7 @@ pgmq is a lightweight message queue built on Postgres. ## Features -- Lightweight - No background worker or external dependencies, just Postgres functions packaged in an extension +- Lightweight - No background worker or external dependencies, only Postgres functions packaged in an extension - "exactly once" delivery of messages to a consumer within a visibility timeout - API parity with AWS SQS and RSMQ - Messages stay in the queue until explicitly removed diff --git a/apps/docs/content/guides/realtime.mdx b/apps/docs/content/guides/realtime.mdx index cd79fccadff..8352d90659b 100644 --- a/apps/docs/content/guides/realtime.mdx +++ b/apps/docs/content/guides/realtime.mdx @@ -20,60 +20,8 @@ Supabase provides a globally distributed [Realtime](https://github.com/supabase/ - **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 + -
-
- - - Showcase application displaying cursor movements and chat messages using Broadcast. - - -
-
- - - Supabase UI chat component using Broadcast to send message between users. - - -
-
- - - Supabase UI avatar stack component using Presence to track connected users. - - -
-
- - - Supabase UI realtime cursor component using Broadcast to share users' cursors to build - collaborative applications. - - -
-
- -## Resources - -Find the source code and documentation in the Supabase GitHub repository. - -
-
- - View the source code. - -
-
- - - Read more about Supabase Realtime. - - -
-
+ diff --git a/apps/docs/content/guides/realtime/architecture.mdx b/apps/docs/content/guides/realtime/architecture.mdx index cbf6b59737f..0d0b0aee9b6 100644 --- a/apps/docs/content/guides/realtime/architecture.mdx +++ b/apps/docs/content/guides/realtime/architecture.mdx @@ -44,7 +44,7 @@ Broadcast lets you send a message from any connected client to a Channel. Any ot This works globally. A client connected to a Realtime node in the United States can send a message to another client connected to a node in Singapore. Connect two clients to the same Realtime Channel and they'll all receive the same messages. -Broadcast is useful for getting messages to users in the same location very quickly. If a group of clients are connected to a node in Singapore, the message only needs to go to that Realtime node in Singapore and back down. If users are close to a Realtime node they'll get Broadcast messages in the time it takes to ping the cluster. +Broadcast is useful for getting messages to users in the same location rapidly. If a group of clients are connected to a node in Singapore, the message only needs to go to that Realtime node in Singapore and back down. If users are close to a Realtime node they'll get Broadcast messages in the time it takes to ping the cluster. Thanks to the Realtime cluster, you (an amazing Supabase user) don't have to think about which regions your clients are connected to. diff --git a/apps/docs/content/guides/realtime/broadcast.mdx b/apps/docs/content/guides/realtime/broadcast.mdx index 457326b1dd3..20a23ed37ba 100644 --- a/apps/docs/content/guides/realtime/broadcast.mdx +++ b/apps/docs/content/guides/realtime/broadcast.mdx @@ -1042,6 +1042,12 @@ You can configure replay with the following options: - **`since`** (Required): The epoch timestamp in milliseconds (for example, `1697472000000`), specifying the earliest point from which messages should be retrieved. - **`limit`** (Optional): The number of messages to return. This must be a positive integer, with a maximum value of 25. + + +Messages are stored in daily partitions, and partitions older than 72 hours are dropped. Because whole days are removed at once, a message stays available for at least 72 hours and at most 4 days, depending on the time of day it was sent. Setting `since` further back than the retained window does not recover deleted messages. See [Realtime Limits](/docs/guides/realtime/limits) for details. + + + -During a `sync` event, you may receive `join` and `leave` events simultaneously, even though no users are actually joining or leaving. This is expected behavior—Presence reconciles its local state with the server state, which can trigger these events as part of the synchronization process. This reflects state reconciliation, not real user movement. +During a `sync` event, you may receive `join` and `leave` events simultaneously, even though no users are joining or leaving. This is expected behavior—Presence reconciles its local state with the server state, which can trigger these events as part of the synchronization process. This reflects state reconciliation, not real user movement.
diff --git a/apps/docs/content/guides/realtime/protocol.mdx b/apps/docs/content/guides/realtime/protocol.mdx index 05c524e642b..30e6c5c99f2 100644 --- a/apps/docs/content/guides/realtime/protocol.mdx +++ b/apps/docs/content/guides/realtime/protocol.mdx @@ -214,7 +214,8 @@ This is the initial message required to join a channel. The client sends this me "replay" : { "since": integer, "limit": integer - } + }, + "replication_ready": boolean }, "presence": { "enabled": boolean, @@ -225,7 +226,8 @@ This is the initial message required to join a channel. The client sends this me "event": string, "schema": string, "table": string, - "filter": string + "filter": string, + "select": string[] } ] "private": boolean @@ -242,6 +244,7 @@ This is the initial message required to join a channel. The client sends this me - `replay`: Configuration options for broadcast replay (Optional) - `since`: Replay messages since a specific timestamp in milliseconds - `limit`: Limit the number of replayed messages (Optional) + - `replication_ready`: When `true`, the server emits a `system` event once the Postgres replication connection backing this channel is established and ready to stream changes (Optional). See the [system](#system) event for the payload shape. - `presence`: Configuration options for presence tracking - `enabled`: Whether presence tracking is enabled for this channel - `key`: Key to be used for presence tracking, if not specified or empty, a UUID will be generated and used @@ -249,7 +252,8 @@ This is the initial message required to join a channel. The client sends this me - `event`: Database change event to listen to, accepts `INSERT`, `UPDATE`, `DELETE`, or `*` to listen to all events. - `schema`: Schema of the table to listen to, accepts `*` wildcard to listen to all schemas - `table`: Table of the database to listen to, accepts `*` wildcard to listen to all tables - - `filter`: Filter to be used when pulling changes from database. Read more about filters in the usage docs for [Postgres Changes](/docs/guides/realtime/postgres-changes?queryGroups=language&language=js#filtering-for-specific-changes) + - `filter`: Filter to be used when pulling changes from the database. A filter is a `column=operator.value` expression (for example `id=eq.1` or `title=like.%foo%`). Multiple conditions can be combined with commas and are applied as an `AND` (for example `id=gt.0,id=lt.100`). Any operator can be negated with the `not.` prefix (for example `status=not.in.(draft,archived)`). Reserved characters (`,`, `(`, `)`) inside a value must be double-quoted PostgREST-style (for example `name=eq."a,b"`). See the [Postgres Changes subscription errors](#postgres-changes-subscription-errors) for the full list of supported operators, and the usage docs for [Postgres Changes](/docs/guides/realtime/postgres-changes?queryGroups=language&language=js#filtering-for-specific-changes). + - `select`: Optional array of column names to restrict the change payload to a subset of columns instead of receiving the full row. Reduces payload size and the data transferred per event. The listed columns must be selectable by the subscribing role. Not supported for wildcard (`*`) schema or table subscriptions — an explicit `schema` and `table` are required. - `access_token`: Optional access token for authentication, if not provided, the server will use the API key. Example on protocol version `2.0.0`: @@ -413,7 +417,7 @@ user-event // User Event } ``` -The payload encoding is just a hint for the client to know if the payload should be treated as JSON or not. +The payload encoding is a hint for the client to know if the payload should be treated as JSON or not. {/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} @@ -564,7 +568,7 @@ The server sends system messages to inform clients about the status of their Rea - `message`: A human-readable message describing the status of the subscription. - `status`: The status of the subscription, can be `ok`, `error`, or `timeout`. -- `extension`: The extension that sent the message. +- `extension`: The extension that sent the message. `postgres_changes` for Postgres Changes subscription status, or `system` for connection-level messages such as the replication-ready notification. - `channel`: The channel to which the message belongs, such as `realtime:room1`. Example on protocol version `2.0.0`: @@ -584,6 +588,23 @@ Example on protocol version `2.0.0`: ] ``` +When a channel is joined with `config.broadcast.replication_ready` set to `true`, the server sends a `system` message with `extension: "system"` once the Postgres replication connection backing the channel is ready to stream changes. `status` is `"ok"` with `message: "Replication connection established"` on success, or `"error"` if the connection is not established in time (which also closes the channel — see [Channel-level system errors](#channel-level-system-errors)). + +```json +[ + "14", + null, + "realtime:chat-room", + "system", + { + "message": "Replication connection established", + "status": "ok", + "extension": "system", + "channel": "main" + } +] +``` + {/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} #### broadcast (text frame) @@ -667,7 +688,7 @@ message // User Event } ``` -The metadata field is JSON encoded. The payload encoding is just a hint for the client to know if the payload should be treated as JSON or not. +The metadata field is JSON encoded. The payload encoding is a hint for the client to know if the payload should be treated as JSON or not. {/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} @@ -715,6 +736,8 @@ The server sends this message when a database change occurs in a subscribed sche - `old_record`: An object representing the old values before the change, with keys as column names and values as their corresponding values. - `errors`: Any errors that occurred during the change, if applicable. +When the subscription was joined with a `select` array (see [phx_join](#phx_join)), `columns`, `record`, and `old_record` are restricted to the selected columns instead of the full row. + ```json [ null, @@ -926,15 +949,16 @@ One exception: the `UnknownErrorOnChannel` code arrives as the bare human-readab `extension: "system"`, `status: "error"`. Match on the `message` field content — there is no machine-readable code field. Every channel-level system error is immediately followed by `phx_close`; the channel is closed. Client libraries should expose a way for users to subscribe to `system` events since there is no automatic handling. -| Message contains | Cause | Recovery | -| ------------------------------------------------- | -------------------------- | ----------------------------- | -| `Too many messages per second` | Broadcast/event rate limit | Throttle sends before rejoin | -| `Too many presence messages per second` | Tenant presence rate limit | Reduce presence frequency | -| `Client presence rate limit exceeded` | Per-client presence window | Longer cooldown before rejoin | -| `Track message size exceeded` | Presence payload too large | Shrink payload | -| `Token has expired` | JWT expired mid-session | Refresh token, rejoin | -| `Fields \`role\` and \`exp\` are required in JWT` | Claims missing | Fix token issuance | -| `Server requested disconnect` | Operational disconnect | Reconnect after delay | +| Message contains | Cause | Recovery | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------- | +| `Too many messages per second` | Broadcast/event rate limit | Throttle sends before rejoin | +| `Too many presence messages per second` | Tenant presence rate limit | Reduce presence frequency | +| `Client presence rate limit exceeded` | Per-client presence window | Longer cooldown before rejoin | +| `Track message size exceeded` | Presence payload too large | Shrink payload | +| `Token has expired` | JWT expired mid-session | Refresh token, rejoin | +| `Fields \`role\` and \`exp\` are required in JWT` | Claims missing | Fix token issuance | +| `Server requested disconnect` | Operational disconnect | Reconnect after delay | +| `Replication connection was not established in time` | Replication connection not ready before the deadline (only when `replication_ready` was requested) | Retry with backoff | ### Postgres Changes subscription errors @@ -948,7 +972,7 @@ One exception: the `UnknownErrorOnChannel` code arrives as the bare human-readab | Database error during subscription | Yes, every 5–10 s | Surface as degraded state | | `"Too many database timeouts"` | No | Reduce subscription load; retry later | -Supported filter operators: `eq`, `neq`, `lt`, `lte`, `gt`, `gte`, `in`. +Supported filter operators: `eq`, `neq`, `lt`, `lte`, `gt`, `gte`, `in`, `like`, `ilike`, `is`, `match`, `imatch`, `isdistinct`. Any operator can be negated with the `not.` prefix (for example `id=not.eq.5`). Multiple conditions are combined with commas and applied as an `AND` (for example `col1=eq.val,col2=gt.5`). The `ids` array on incoming `postgres_changes` payloads must match the subscription IDs returned in the `phx_join` reply. A mismatch means inconsistent server/client state — tear down and rejoin. @@ -962,7 +986,7 @@ When `ack` is `true`, the server replies on error with `response.error` (an atom { "status": "error", "response": { "error": "payload_size_exceeded" } } ``` -Note that the JS client (`send()`) resolves to just the string `'error'` and does not expose the specific `error` atom to callers. +Note that the JS client (`send()`) resolves to the string `'error'` and does not expose the specific `error` atom to callers. ### Presence errors diff --git a/apps/docs/content/guides/realtime/reports.mdx b/apps/docs/content/guides/realtime/reports.mdx index b17d012ecf2..23b10880524 100644 --- a/apps/docs/content/guides/realtime/reports.mdx +++ b/apps/docs/content/guides/realtime/reports.mdx @@ -157,7 +157,7 @@ height={625} ## Rate of Channel Joins -The Rate of Channel Joins report helps you monitor how quickly clients are joining Realtime channels over time. This metric is essential for understanding your application's channel subscription patterns and identifying when you're approaching your plan's channel join rate limits. +The Rate of Channel Joins report helps you monitor how fast clients are joining Realtime channels over time. This metric is essential for understanding your application's channel subscription patterns and identifying when you're approaching your plan's channel join rate limits. The report displays the rate of channel joins per second, showing how frequently clients subscribe to channels throughout the selected time period. A channel join occurs whenever a client subscribes to a channel topic to receive real-time updates. Each client connection can join multiple channels (up to 100 per connection for most plans), and the join rate measures how many of these subscriptions happen per second across your entire project. @@ -377,7 +377,7 @@ height={645} The Response Speed report helps you monitor the average response time for HTTP requests to the Realtime service over time. This metric is essential for understanding API performance, identifying latency issues, and ensuring your real-time features meet performance expectations. -The report displays the average response time in milliseconds, showing how quickly the Realtime service responds to HTTP requests throughout the selected time period. This includes response times for REST API requests such as broadcast messages, WebSocket upgrade requests, and other HTTP-based interactions. Higher response times can indicate performance bottlenecks, database load issues, or network problems that may impact the real-time responsiveness of your application. +The report displays the average response time in milliseconds, showing how fast the Realtime service responds to HTTP requests throughout the selected time period. This includes response times for REST API requests such as broadcast messages, WebSocket upgrade requests, and other HTTP-based interactions. Higher response times can indicate performance bottlenecks, database load issues, or network problems that may impact the real-time responsiveness of your application. Response Speed chart + **How often is Supabase audited?** Supabase undergoes annual audits. The HIPAA controls are audited during the same audit period as the SOC 2 controls. diff --git a/apps/docs/content/guides/security/npm-security.mdx b/apps/docs/content/guides/security/npm-security.mdx index f41d6e9eede..0eabaf57113 100644 --- a/apps/docs/content/guides/security/npm-security.mdx +++ b/apps/docs/content/guides/security/npm-security.mdx @@ -4,7 +4,7 @@ title: 'Securing npm installs' description: 'Consumer-side guide to hardening your npm installs of Supabase packages against supply-chain attacks.' --- -A practical guide for anyone installing Supabase packages from npm — the JavaScript client libraries (`@supabase/supabase-js` and friends), the `supabase` CLI, or any other dependency in your tree — on defending against supply-chain attacks. Most of it applies to any npm package, not just Supabase's. +A practical guide for anyone installing Supabase packages from npm — the JavaScript client libraries (`@supabase/supabase-js` and friends), the `supabase` CLI, or any other dependency in your tree — on defending against supply-chain attacks. Most of it applies to any npm package, not only Supabase's. @@ -38,7 +38,7 @@ A committed lockfile is the floor, not the ceiling. - Commit `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, or `bun.lock`. - In CI, install with `npm ci` / `pnpm install --frozen-lockfile` / `yarn install --immutable` / `bun install --frozen-lockfile`. These fail if `package.json` and the lockfile disagree, which is what you want. -- Caret ranges (`^1.2.3`) in `package.json` are fine **if** you also have a lockfile and the rest of the guidance below — the lockfile is what actually gets installed. +- Caret ranges (`^1.2.3`) in `package.json` are fine **if** you also have a lockfile and the rest of the guidance below — the lockfile is what gets installed. **Pin transitive risk with overrides.** If you don't trust a particular transitive dep version, force a known-good version via: @@ -102,7 +102,7 @@ npmPreapprovedPackages: # opt specific packages out of all package gates - '@your-org/*' ``` -Versions newer than the gate are excluded from resolution. Yarn's docs also note this guards against the npm registry's 72-hour unpublish window — a package you just installed could vanish, breaking your build, if you don't wait it out. +Versions newer than the gate are excluded from resolution. Yarn's docs also note this guards against the npm registry's 72-hour unpublish window — a package you recently installed could vanish, breaking your build, if you don't wait it out. Two related yarn settings worth knowing about while you're in `.yarnrc.yml`: @@ -141,7 +141,7 @@ Or per-command: bun add @supabase/supabase-js --minimum-release-age 604800 ``` -Bun's age gate only affects new resolutions — existing entries in `bun.lock` are unchanged. It also runs a stability check: if multiple versions were published close together just outside your gate, Bun extends the filter to skip those (likely unstable) versions and picks an older, more mature one. Exact-version requests (`pkg@1.1.1`) respect the gate but bypass the stability extension. +Bun's age gate only affects new resolutions — existing entries in `bun.lock` are unchanged. It also runs a stability check: if multiple versions were published close together outside your gate, Bun extends the filter to skip those (likely unstable) versions and picks an older, more mature one. Exact-version requests (`pkg@1.1.1`) respect the gate but bypass the stability extension. For Deno-based Edge Functions, see the [Edge Functions specifics](#edge-functions-specifics) section below. @@ -230,7 +230,7 @@ Corepack (bundled with modern Node) and `pnpm/action-setup@v6+` both read this f ## Prune unused dependencies -Every dependency you don't actually need is attack surface you don't actually need. Two cheap habits: +Every dependency you don't need is attack surface you don't need. Two cheap habits: - Periodically run `npx depcheck` (or the equivalent for your stack) and remove dependencies that aren't imported anywhere. - Look at your direct dependencies when a CVE lands. Is the dep doing something you could do in 20 lines yourself? Some of the most-exploited packages are tiny utilities that became transitive footguns. @@ -267,7 +267,7 @@ Talk to the Supabase Functions team if your security posture depends on a featur ## If you suspect you installed a compromised version -Move quickly. Order of operations: +Act promptly. Order of operations: 1. **Treat the install host as potentially compromised.** Anything readable by the user that ran the install — env vars, files, secrets in memory — should be assumed stolen. 2. **Rotate credentials reachable from that host**: cloud provider keys (AWS, GCP, Azure), Kubernetes / Vault tokens, GitHub tokens, npm tokens, SSH keys, and any Supabase service-role keys or anon keys that touched the box. diff --git a/apps/docs/content/guides/security/soc-2-compliance.mdx b/apps/docs/content/guides/security/soc-2-compliance.mdx index 7e202e4d2a4..e982263ab2c 100644 --- a/apps/docs/content/guides/security/soc-2-compliance.mdx +++ b/apps/docs/content/guides/security/soc-2-compliance.mdx @@ -40,6 +40,8 @@ SOC 2 compliance is a critical aspect of data security for Supabase and our cust 4. **Control Compliance**: If a customer needs to be SOC 2 compliant, they should themselves implement the requisite controls and undergo a SOC 2 audit. 5. **Audit logging**: Supabase sets [Postgres connection logging](/docs/guides/platform/postgres-connection-logging) to off by default for new projects. If your SOC 2 program requires connection audit evidence, enable connection logging and define how you retain and review those logs. +<$Partial path="log_connections_default_effective_date.mdx" /> + #### Shared responsibilities 1. **Data Security**: Both customers and Supabase share the responsibility of ensuring data security. While the Supabase, as the provider, implements the security controls, the customer must ensure that their use of the Supabase platform does not compromise these controls. diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 348cc44558e..e029e8e2618 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -336,7 +336,7 @@ For transaction-mode connections: psql 'postgres://postgres.[POOLER_TENANT_ID]:[POSTGRES_PASSWORD]@[your-domain]:6543/postgres' ``` -When using `psql` with command-line parameters instead of a connection string to connect to Supavisor, the `-U` parameter should also be `postgres.[POOLER_TENANT_ID]`, and not just `postgres`. +When using `psql` with command-line parameters instead of a connection string to connect to Supavisor, the `-U` parameter should also be `postgres.[POOLER_TENANT_ID]`. If you need to configure Postgres to be directly accessible from the Internet, read [Exposing your Postgres database](#exposing-your-postgres-database). @@ -514,9 +514,10 @@ sh run.sh recreate The `generate-keys.sh` script sets the following secrets automatically. You can also configure them manually in the `.env` file if needed: - `SECRET_KEY_BASE`: encryption key for securing Realtime and Supavisor communications. (Must be at least 64 characters; generate with `openssl rand -base64 48`) +- `REALTIME_DB_ENC_KEY`: encryption key used by Realtime for sensitive fields in the `_realtime` schema. (Must be exactly 16 characters; generate with `openssl rand -hex 8`) - `VAULT_ENC_KEY`: encryption key used by Supavisor for storing encrypted configuration. (Must be exactly 32 characters; generate with `openssl rand -hex 16`) - `PG_META_CRYPTO_KEY`: encryption key for securing connection strings used by Studio against postgres-meta. (Must be at least 32 characters; generate with `openssl rand -base64 24`) -- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion and querying. Used by Vector and Studio to send and query logs. (Must be at least 32 characters; generate with `openssl rand -base64 24`) +- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion used by Logflare and Vector. (Must be at least 32 characters; generate with `openssl rand -base64 24`) - `LOGFLARE_PRIVATE_ACCESS_TOKEN`: API token for Logflare management operations. Used by Studio for administrative tasks. Never expose client-side. (Must be at least 32 characters; generate with `openssl rand -base64 24`) - `S3_PROTOCOL_ACCESS_KEY_ID`: Access key ID (username-like) for [accessing](/docs/guides/self-hosting/self-hosted-s3) the S3 protocol endpoint in Storage. (Generate with `openssl rand -hex 16`) - `S3_PROTOCOL_ACCESS_KEY_SECRET`: Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID. (Generate with `openssl rand -hex 32`) diff --git a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx index 63552870758..221bf5cb80d 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx @@ -61,7 +61,7 @@ storage: -Podman does not support nested variable interpolation (`${A:-${B}}`). If you are using Podman, replace each nested expression with the required variable directly - see the inline comments in `docker-compose.yml` for the exact substitutions. +Nested variable interpolation (`${A:-${B}}`) requires `podman-compose >= 1.6.0`. Earlier versions (still shipped by some Linux distributions) do not support it - if you are on an older `podman-compose`, either upgrade or replace each nested expression with the required variable directly, see the inline comments in `docker-compose.yml` for the exact substitutions. diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx index cfec33dc403..7ab4da3df8b 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -199,7 +199,7 @@ For more details, see: ### 400 "missing function name in request" -The request URL must include the function name after `/functions/v1/`. For example, `/functions/v1/hello` — not just `/functions/v1/`. +The request URL must include the function name after `/functions/v1/`. For example, `/functions/v1/hello`. ### 500 error on invocation @@ -230,7 +230,7 @@ docker compose restart functions --no-deps - Recreate the functions container after changing configuration - Check that the variable name matches exactly (case-sensitive) -Use the following command to recreate the container, not just `restart`: +Use the following command to recreate the container: ```sh docker compose up -d --force-recreate --no-deps functions diff --git a/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx b/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx index 6f140fb0565..5e2d658cd42 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx @@ -170,7 +170,7 @@ MFA_MAX_ENROLLED_FACTORS=5 ## Troubleshooting -### OTP expires too quickly +### OTP expires too soon The default `SMS_OTP_EXP` is 60 seconds. Increase it in `.env`: diff --git a/apps/docs/content/guides/storage.mdx b/apps/docs/content/guides/storage.mdx index 81643f11439..a918edf83a1 100644 --- a/apps/docs/content/guides/storage.mdx +++ b/apps/docs/content/guides/storage.mdx @@ -17,91 +17,8 @@ Supabase Storage is a robust, scalable solution for managing files of any size w - **Fine-grained Access Control** - Manage file permissions with row-level security and custom policies - **Multiple Bucket Types** - Specialized storage solutions for different use cases -## Storage bucket types + -Supabase Storage offers different bucket types optimized for specific use cases: + -### Files buckets - -Store and serve traditional files including images, videos, documents, and general-purpose content. Ideal for user-generated content, media libraries, and asset management. - -**Use cases:** Images, videos, documents, PDFs, archives - -**Features:** - -- Global CDN delivery -- Image optimization and transformation -- Row-level security integration -- Direct URL access for files - -[Learn more about Files Buckets](/docs/guides/storage/quickstart) - -### Analytics buckets - -Purpose-built for storing and analyzing data in open table formats like Apache Iceberg. Perfect for time-series data, logs, and large-scale analytical workloads. - -**Use cases:** Data lakes, analytics pipelines, ETL operations, historical data analysis - -**Features:** - -- Apache Iceberg table format support -- SQL-accessible via Postgres foreign tables -- Partitioned data organization -- Efficient data querying and transformation - -[Learn more about Analytics Buckets](/docs/guides/storage/analytics/introduction) - -### Vector buckets - -Specialized storage for vector embeddings and similarity search operations. Designed for AI and ML applications requiring semantic search capabilities. - -**Use cases:** AI-powered search, semantic similarity matching, embedding storage, RAG systems - -**Features:** - -- Optimized vector indexing (HNSW, Flat) -- Multiple distance metrics (cosine, euclidean, L2) -- Metadata filtering for vectors -- Similarity search queries - -[Learn more about Vector Buckets](/docs/guides/storage/vector/introduction) - -## Examples - -Check out all of the Storage [templates and examples](https://github.com/supabase/supabase/tree/master/examples/storage) in our GitHub repository. - -
-
- - - Use Uppy to upload files to Supabase Storage using the TUS protocol (resumable uploads). - - -
-
- -## Resources - -Find the source code and documentation in the Supabase GitHub repository. - -
-
- - View the source code. - -
-
- - - See the Swagger Documentation for Supabase Storage. - - -
-
+ diff --git a/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx b/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx index 2dbb3f27a6a..f9a6c7b1fe2 100644 --- a/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx +++ b/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx @@ -13,7 +13,7 @@ Analytics buckets use [Apache Iceberg](https://iceberg.apache.org/), an open-tab -Analytics Buckets are still available, but managed replication into Analytics Buckets through Supabase ETL is no longer supported. If you need managed replication today, use [external replication (ETL)](/docs/guides/database/replication/external-replication-setup) with **BigQuery**. If you want to use Analytics Buckets, bring your own ingestion pipeline. +Analytics Buckets are still available, but replication into Analytics Buckets via Supabase Pipelines is no longer supported. If you want to use Analytics Buckets, bring your own ingestion pipeline. diff --git a/apps/docs/content/guides/storage/analytics/query-with-postgres.mdx b/apps/docs/content/guides/storage/analytics/query-with-postgres.mdx index 41c296a0999..1bba106229b 100644 --- a/apps/docs/content/guides/storage/analytics/query-with-postgres.mdx +++ b/apps/docs/content/guides/storage/analytics/query-with-postgres.mdx @@ -9,7 +9,7 @@ This is made possible by the [Iceberg Foreign Data Wrapper](/docs/guides/databas -Managed replication into Analytics Buckets through Supabase ETL is no longer supported. This guide assumes your Analytics Bucket is being populated by your own ingestion pipeline. If you need managed replication today, use [external replication (ETL)](/docs/guides/database/replication/external-replication-setup) with **BigQuery**. +Replication into Analytics Buckets via Supabase Pipelines is no longer supported. This guide assumes your Analytics Bucket is being populated by your own ingestion pipeline. diff --git a/apps/docs/content/guides/storage/cdn/purge-cdn-cache.mdx b/apps/docs/content/guides/storage/cdn/purge-cdn-cache.mdx new file mode 100644 index 00000000000..81627a48985 --- /dev/null +++ b/apps/docs/content/guides/storage/cdn/purge-cdn-cache.mdx @@ -0,0 +1,120 @@ +--- +id: 'storage-cdn-purge-cache' +title: 'Purge CDN Cache' +description: 'Learn how to manually purge Supabase Storage CDN cache.' +sidebar_label: 'CDN' +--- + +With Smart CDN enabled, Supabase Storage automatically invalidates the cache when files are updated or deleted. However, there are scenarios where you may need to manually purge the CDN cache for specific objects or entire buckets. The cache purge API allows you to immediately queue cache content invalidation across all CDN edge nodes. + +Manual cache purging is useful when you need to ensure that updates are propagated as soon as possible, or when you want to clear the cache for debugging purposes. Once purged, the next request for that object will be served from the origin server, and the CDN cache will be repopulated. + + + +Cache purging requires the **secret key**. The server rejects calls made with the legacy anon key or a user JWT. Never expose your secret key in client-side code. + + + + + +CDN cache purge is available for [Pro Plan and above](/pricing). + + + +## Purge a single object + +You can purge the CDN cache for a specific file by providing the exact path to the object. This operation does not support wildcards or recursion. You must specify the complete path of the file you want to invalidate. + + + + +```javascript +import { createClient } from '@supabase/supabase-js' + +// Create Supabase client with secret key +const supabase = createClient('your_project_url', 'your_secret_key') + +// Purge cache for a single object +async function purgeCachedObject() { + const { data, error } = await supabase.storage + .from('bucket_name') + .purgeCache('folder_name/file_name.png') + + if (error) { + // Handle error + } else { + // Handle success + console.log(data.message) // 'success' + } +} +``` + + + + +```bash +curl -X DELETE "https://{your_project_ref}.supabase.co/storage/v1/cdn/bucket_name/folder_name/file_name.png" \ + -H "apikey: {your_secret_key}" + +# If using legacy jwt keys use this header: Authorization: Bearer {your_service_role_jwt} +``` + + + + +## Purge an entire bucket + +For scenarios where you need to invalidate all cached objects in a bucket, you can purge the entire bucket's cache. This is useful when performing bulk updates or major changes to your storage bucket. + + + + +```javascript +import { createClient } from '@supabase/supabase-js' + +// Create Supabase client with secret key +const supabase = createClient('your_project_url', 'your_secret_key') + +// Purge cache for an entire bucket +async function purgeBucketCache() { + const { data, error } = await supabase.storage.purgeBucketCache('bucket_name') + + if (error) { + // Handle error + } else { + // Handle success + console.log(data.message) // 'success' + } +} +``` + + + + +```bash +curl -X DELETE "https://{your_project_ref}.supabase.co/storage/v1/cdn/bucket_name" \ + -H "apikey: {your_secret_key}" + +# If using legacy jwt keys use this header: Authorization: Bearer {your_service_role_jwt} +``` + + + + +## Cache propagation + +After purging the cache, it can take **up to 60 seconds** for the invalidation to propagate across all CDN edge nodes worldwide. During this time, some users may still receive cached content depending on which edge node they are routed to. + +Keep in mind that purging the CDN cache does not affect browser caches. If users have the asset cached locally in their browser, they will continue to see the cached version until the browser cache expires based on the `cacheControl` value set during upload. diff --git a/apps/docs/content/guides/storage/cdn/smart-cdn.mdx b/apps/docs/content/guides/storage/cdn/smart-cdn.mdx index df408ce497e..802b481d9e7 100644 --- a/apps/docs/content/guides/storage/cdn/smart-cdn.mdx +++ b/apps/docs/content/guides/storage/cdn/smart-cdn.mdx @@ -39,7 +39,7 @@ For instance, you can use a URL like `/storage/v1/object/sign/profile-pictures/c ## Signed URLs and CDN caching -Signed URLs are the primary way to serve assets from private buckets to end users. With Smart CDN enabled, signed URL responses are cached at the CDN edge, just like any other storage request. +Signed URLs are the primary way to serve assets from private buckets to end users. With Smart CDN enabled, signed URL responses are cached at the CDN edge, like any other storage request. Unlike public bucket URLs, each signed URL contains a unique token query parameter (`?token=...`). Smart CDN treats each unique token as a separate cache key, meaning the first request with any given signed URL results in a cache miss, and only subsequent requests using that exact same URL will receive a cache hit. Two different signed URLs for the same object, even if generated seconds apart, each maintain their own independent cache entry. diff --git a/apps/docs/content/guides/storage/quickstart.mdx b/apps/docs/content/guides/storage/quickstart.mdx index 8a8f781e640..18175bee714 100644 --- a/apps/docs/content/guides/storage/quickstart.mdx +++ b/apps/docs/content/guides/storage/quickstart.mdx @@ -19,7 +19,7 @@ Files can be any sort of media file. This includes images, GIFs, and videos. It ### Folders -Folders are a way to organize your files (just like on your computer). There is no right or wrong way to organize your files. You can store them in whichever folder structure suits your project. +Folders are a way to organize your files (like on your computer). There is no right or wrong way to organize your files. You can store them in whichever folder structure suits your project. ### Buckets diff --git a/apps/docs/content/guides/storage/schema/custom-roles.mdx b/apps/docs/content/guides/storage/schema/custom-roles.mdx index 5edc3802f40..889e257d4bb 100644 --- a/apps/docs/content/guides/storage/schema/custom-roles.mdx +++ b/apps/docs/content/guides/storage/schema/custom-roles.mdx @@ -39,7 +39,7 @@ using ( ## Test the policy To impersonate the `manager` role, you will need a valid JWT token with the `manager` role. -You can quickly create one using the `jsonwebtoken` library in Node.js. +You can create one using the `jsonwebtoken` library in Node.js. diff --git a/apps/docs/content/guides/storage/schema/design.mdx b/apps/docs/content/guides/storage/schema/design.mdx index f2d380a8dd4..a7e1905a721 100644 --- a/apps/docs/content/guides/storage/schema/design.mdx +++ b/apps/docs/content/guides/storage/schema/design.mdx @@ -16,11 +16,41 @@ This is important because the storage schema only stores the metadata and the ac -Here is the schema that represents the Storage service: +Here is the schema that represents the Storage service, you have the option to query this table directly to retrieve information about your files in Storage without the need to go through our API. -Storage schema design +```mermaid +erDiagram + buckets ||--o{ objects : "buckets_id:id" + buckets { + text id PK + text name + timestamptz created_at + timestamptz updated_at + boolean public + bigint file_size_limit + text[] allowed_mime_types + text owner_id + } + objects { + uuid id PK + text bucket_id FK + text name + timestamptz created_at + timestamptz updated_at + jsonb metadata + text[] path_tokens + text version + text owner_id + } + migrations { + integer id PK + varchar(100) name + varchar(40) hash + timestamp executed_at + } +``` -You have the option to query this table directly to retrieve information about your files in Storage without the need to go through our API. +The schema centers on three tables. Each `buckets` row can own many `objects` rows by a one-to-many relationship that joins `buckets.id` to `objects.bucket_id`. The `buckets` table holds bucket configuration. Its `id` and `name`, whether the bucket is `public`, and constraints such as `file_size_limit` and `allowed_mime_types`. The `objects` table holds per-file metadata: the owning `bucket_id`, the object `name` and `path_tokens`, a `metadata` JSON blob, and version information. The `migrations` table is standalone and tracks the schema migrations applied to the storage service. ## Modifying the schema diff --git a/apps/docs/content/guides/storage/security/ownership.mdx b/apps/docs/content/guides/storage/security/ownership.mdx index 10f724d6841..dd0d6aa8ae2 100644 --- a/apps/docs/content/guides/storage/security/ownership.mdx +++ b/apps/docs/content/guides/storage/security/ownership.mdx @@ -36,4 +36,4 @@ using ( ); ``` -The use of RLS policies is just one way to enforce access control. You can also implement access control in your server code by following the same pattern. +The use of RLS policies is one way to enforce access control. You can also implement access control in your server code by following the same pattern. diff --git a/apps/docs/content/guides/telemetry/logs.mdx b/apps/docs/content/guides/telemetry/logs.mdx index 9f5d9ae85bc..0d41674f528 100644 --- a/apps/docs/content/guides/telemetry/logs.mdx +++ b/apps/docs/content/guides/telemetry/logs.mdx @@ -147,6 +147,8 @@ Do not log Personal Identifiable Information (PII) within the `User-Agent` heade Postgres can log connection lifecycle events to your project's Postgres logs, for example when a client connects or authenticates. By default, Supabase sets `log_connections` to off for new projects and you must enable it first. +<$Partial path="log_connections_default_effective_date.mdx" /> + To enable connection logging for audit or compliance, see [Postgres connection logging](/docs/guides/platform/postgres-connection-logging). In the [Logs Explorer](/dashboard/project/_/logs-explorer), connection lifecycle messages may be hidden by default. Use the connection logs filter in the sidebar to show them. diff --git a/apps/docs/content/navigation.references.ts b/apps/docs/content/navigation.references.ts index dc4f4c137a0..b1a1fc10972 100644 --- a/apps/docs/content/navigation.references.ts +++ b/apps/docs/content/navigation.references.ts @@ -39,8 +39,10 @@ export const REFERENCES = { icon: 'reference-dart', meta: { v2: { + // Dart v2 is driven by the new reference pipeline + // (`scripts/build-reference-content.ts` + `spec/reference/dart/v2/`). + // It intentionally has no `specFile`, so the legacy YAML loader skips it. libId: 'reference_dart_v2', - specFile: 'supabase_dart_v2', }, v1: { libId: 'reference_dart_v1', diff --git a/apps/docs/content/troubleshooting/disabling-prepared-statements-qL8lEL.mdx b/apps/docs/content/troubleshooting/disabling-prepared-statements-qL8lEL.mdx index bddc58bf5f7..c9741c78913 100644 --- a/apps/docs/content/troubleshooting/disabling-prepared-statements-qL8lEL.mdx +++ b/apps/docs/content/troubleshooting/disabling-prepared-statements-qL8lEL.mdx @@ -31,7 +31,7 @@ export const client = postgres(connectionString, { prepare: false }) ## Node Postgres -[Just omit the "name" value in a query definition](https://node-postgres.com/features/queries#prepared-statements): +[Omit the "name" value in a query definition](https://node-postgres.com/features/queries#prepared-statements): ```ts const query = { diff --git a/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx index c28c5761e0f..2351c1cb1e0 100644 --- a/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx @@ -123,7 +123,7 @@ const some_num = 5 some_num() // TypeError: some_num is not a function ``` -This issue often appears when working with returned objects from external APIs. One may assume a response has a certain shape, but if the value is actually `null` or `undefined`, using it without checking can lead to a `TypeError`. +This issue often appears when working with returned objects from external APIs. One may assume a response has a certain shape, but if the value is `null` or `undefined`, using it without checking can lead to a `TypeError`. ```js const data = await req.json() // returns undefined if request body is empty @@ -277,7 +277,7 @@ if (req.method === 'OPTIONS') { } ``` -So, when encountering these errors, it is still important to check the logs or run the request outside the browser to make sure it is actually the primary factor and not a side-effect of a larger issue. +So, when encountering these errors, it is still important to check the logs or run the request outside the browser to make sure it is the primary factor and not a side-effect of a larger issue. ## Still stuck? diff --git a/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx index cc869fa405a..9f4b12e25d0 100644 --- a/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx @@ -252,7 +252,7 @@ If you believe a portion of your function is overly aggressive, try testing loca Common culprits: -**CPU intensive recursions**: intensive loops or recursion can quickly exhaust CPU +**CPU intensive recursions**: intensive loops or recursion can exhaust CPU ```js // This will exhaust CPU allocation when called repeatedly @@ -288,7 +288,7 @@ If you are performing logic to process data from Supabase Postgres, you may be a ### 4. Offload operations to an external API: -Instead of managing all operations within the function itself, there may be an external API that can execute CPU or memory intensive jobs on its behalf. One [example](/docs/guides/functions/examples/screenshots) would be using an external API for orchestrating a headless browser and then just using the edge function to manage the output of the activity instead of everything all in place. +Instead of managing all operations within the function itself, there may be an external API that can execute CPU or memory intensive jobs on its behalf. One [example](/docs/guides/functions/examples/screenshots) would be using an external API for orchestrating a headless browser and then using the edge function to manage the output of the activity instead of everything all in place. ### 5. Split operations into individual functions: @@ -312,7 +312,7 @@ Performing edits against images or other large files can be both CPU and Memory ### AI embedding generation and inference -AI models process data into embeddings (large arrays), that they can more understand. Edge Functions are capable of managing [some small models directly](/blog/ai-inference-now-available-in-supabase-edge-functions); however, some require more processing power than what the edge function can support directly. In these cases, the solution is to manage the embeddings via an external source, such as OpenAI, Anthropic, etc. and to just use the edge function for light processing and coordination. +AI models process data into embeddings (large arrays), that they can more understand. Edge Functions are capable of managing [some small models directly](/blog/ai-inference-now-available-in-supabase-edge-functions); however, some require more processing power than what the edge function can support directly. In these cases, the solution is to manage the embeddings via an external source, such as OpenAI, Anthropic, etc. and to use the edge function for light processing and coordination. ### Web scraping diff --git a/apps/docs/content/troubleshooting/edge-function-dependency-analysis.mdx b/apps/docs/content/troubleshooting/edge-function-dependency-analysis.mdx index 9d1f742018b..b1b764d3d78 100644 --- a/apps/docs/content/troubleshooting/edge-function-dependency-analysis.mdx +++ b/apps/docs/content/troubleshooting/edge-function-dependency-analysis.mdx @@ -26,7 +26,7 @@ Review the output for: - **Large dependencies:** Packages that contribute significantly to bundle size - **Redundant imports:** Multiple packages providing similar functionality - **Outdated versions:** Dependencies that can be updated to more efficient versions -- **Unused imports:** Dependencies imported but not actually used in your code +- **Unused imports:** Dependencies imported but not used in your code ## Optimizing NPM dependencies @@ -39,18 +39,17 @@ Import specific submodules to minimize overhead: ```tsx // Good: Import specific submodules import { Sheets } from 'npm:@googleapis/sheets' +import * as googleAuth from 'npm:google-auth-library' import { JWT } from 'npm:google-auth-library/build/src/auth/jwtclient' - // Avoid: Import entire package import * as googleapis from 'npm:googleapis' -import * as googleAuth from 'npm:google-auth-library' ``` ## Best practices ### Tree-shake aggressively -Only import what you actually use. Avoid wildcard imports (`import *`) when possible. +Only import what you use. Avoid wildcard imports (`import *`) when possible. ### Choose lightweight alternatives diff --git a/apps/docs/content/troubleshooting/edge-function-shutdown-reasons-explained.mdx b/apps/docs/content/troubleshooting/edge-function-shutdown-reasons-explained.mdx index e1900cefcfc..eca96448772 100644 --- a/apps/docs/content/troubleshooting/edge-function-shutdown-reasons-explained.mdx +++ b/apps/docs/content/troubleshooting/edge-function-shutdown-reasons-explained.mdx @@ -80,11 +80,11 @@ These events are surfaced through logs and observability tools, allowing you to ### EarlyDrop -**What it means:** The runtime detected that the function has completed all its work and can be shut down early, before reaching any resource limits. This is actually the most common shutdown reason and typically indicates efficient function execution. +**What it means:** The runtime detected that the function has completed all its work and can be shut down early, before reaching any resource limits. This is the most common shutdown reason and typically indicates efficient function execution. **When it happens:** Your function has finished processing the request, sent the response, and has no remaining async work (pending promises, timers, or callbacks). The runtime recognizes that the worker can be safely terminated without waiting for timeouts or other limits. -**Why this is good:** `EarlyDrop` means your function is running efficiently. It completed quickly, didn't exhaust resources, and the runtime could reclaim the worker for other requests. Most well-designed functions should end with `EarlyDrop`. +**Why this is good:** `EarlyDrop` means your function is running efficiently. It completed without exhausting resources, and the runtime could reclaim the worker for other requests. Most well-designed functions should end with `EarlyDrop`. **What to do:** diff --git a/apps/docs/content/troubleshooting/error-index-row-size-exceeds-btree-version-4-maximum-for-index-LMmoeU.mdx b/apps/docs/content/troubleshooting/error-index-row-size-exceeds-btree-version-4-maximum-for-index-LMmoeU.mdx index dc9439700af..5d9df4a6c53 100644 --- a/apps/docs/content/troubleshooting/error-index-row-size-exceeds-btree-version-4-maximum-for-index-LMmoeU.mdx +++ b/apps/docs/content/troubleshooting/error-index-row-size-exceeds-btree-version-4-maximum-for-index-LMmoeU.mdx @@ -62,6 +62,6 @@ select * from table_name where column_name = 'search_value'; [More on building index by expression](https://www.postgresql.org/docs/current/sql-createindex.html) -For some datatypes other than text that allows queries by partial inclusion (i.e. that the pair key-value is includes in a JSON or for implementing tsvector phrase search) you'd just use GIST/GIN indexes that inherently have values space much narrower that the whole to be indexed. +For some non-text datatypes that support queries by partial inclusion—such as checking whether a key-value pair exists in JSON, or implementing `tsvector` phrase search—use GiST or GIN indexes. These index types operate on a narrower value space than the full content being indexed. [More on GIN/GiST indexes](https://www.postgresql.org/docs/15/textsearch-indexes.html) diff --git a/apps/docs/content/troubleshooting/high-cpu-and-slow-queries-with-error-must-be-a-superuser-to-terminate-superuser-process.mdx b/apps/docs/content/troubleshooting/high-cpu-and-slow-queries-with-error-must-be-a-superuser-to-terminate-superuser-process.mdx index b676cdf8c54..22d1be7e136 100644 --- a/apps/docs/content/troubleshooting/high-cpu-and-slow-queries-with-error-must-be-a-superuser-to-terminate-superuser-process.mdx +++ b/apps/docs/content/troubleshooting/high-cpu-and-slow-queries-with-error-must-be-a-superuser-to-terminate-superuser-process.mdx @@ -40,7 +40,7 @@ This situation often arises in large, high-write tables (e.g., `your_table`, whi ### **Mitigating performance impact during a critical autovacuum** -Since the wraparound prevention autovacuum cannot be stopped, the best approach is to provide the database with sufficient resources to complete the operation as quickly and efficiently as possible. +Since the wraparound prevention autovacuum cannot be stopped, the best approach is to provide the database with sufficient resources to complete the operation as efficiently as possible. 1. **Upgrade your Database Compute Instance:** - **Action:** Temporarily scale up your instance's CPU (e.g., from `m6g.4xlarge` to `m6g.8xlarge` or higher). diff --git a/apps/docs/content/troubleshooting/high-latency-with-supabase-client-z0pZzR.mdx b/apps/docs/content/troubleshooting/high-latency-with-supabase-client-z0pZzR.mdx index 13ab6fdba1f..5f84bbb234d 100644 --- a/apps/docs/content/troubleshooting/high-latency-with-supabase-client-z0pZzR.mdx +++ b/apps/docs/content/troubleshooting/high-latency-with-supabase-client-z0pZzR.mdx @@ -92,8 +92,8 @@ if __name__ == "__main__": print(f"postgres: {ref}, supabase: {sup}, ratio: {sup/ref}") ``` -3. You will see that the Supabase client takes longer to execute the same query, especially for smaller tables or queries returning just one row. +3. You will see that the Supabase client takes longer to execute the same query, especially for smaller tables or queries returning only one row. ## Expected behavior -The overhead from PostgREST shouldn't be higher than a few milliseconds at max. 60-70 ms is way too high. This is particular deceiving because one can run the query on the SQL Editor page and it reports the same time as the direct Postgres query, which is not what actually happens. +The overhead from PostgREST shouldn't be higher than a few milliseconds at max. 60-70 ms is way too high. This is particular deceiving because one can run the query on the SQL Editor page and it reports the same time as the direct Postgres query, which is not what happens. diff --git a/apps/docs/content/troubleshooting/how-postgres-chooses-which-index-to-use-_JHrf4.mdx b/apps/docs/content/troubleshooting/how-postgres-chooses-which-index-to-use-_JHrf4.mdx index 914c5b6cc4c..ac3880bae98 100644 --- a/apps/docs/content/troubleshooting/how-postgres-chooses-which-index-to-use-_JHrf4.mdx +++ b/apps/docs/content/troubleshooting/how-postgres-chooses-which-index-to-use-_JHrf4.mdx @@ -77,7 +77,7 @@ In most cases, developers work with the default BTREE index. It is the most prac An operator's functional equivalents, such as `IN`, `BETWEEN`, and `ANY`, are also valid. -However, just because the base requirements (relevant column, filter, and operators) are present, doesn't mean that an index will be used. +However, an index isn't guaranteed to be used, even when the base requirements (relevant column, filter, and operators) are present. Indexes have a startup cost, so for small tables, Postgres might use a sequential scan if it believes that it will take less time. The database keeps statistics about each table that it uses to inform these choices. @@ -141,7 +141,7 @@ select * from test1 where lower(col1) = 'value'; #### Covering indexes -Indexes contain pointers to a specific row, but you could instruct an index to actually hold a copy of a column's value for even faster retrieval. These are known as `covering` indexes. Because maintaining a copy is storage intensive, you should avoid using it for values with large data footprints.[ FULL VIDEO ON TOPIC](https://www.youtube.com/watch?v=bBu_V8CfWgM) +Indexes contain pointers to a specific row, but you could instruct an index to hold a copy of a column's value for even faster retrieval. These are known as `covering` indexes. Because maintaining a copy is storage intensive, you should avoid using it for values with large data footprints.[ FULL VIDEO ON TOPIC](https://www.youtube.com/watch?v=bBu_V8CfWgM) ```sql CREATE INDEX a_b_idx ON x (a,b) INCLUDE (c); @@ -149,7 +149,7 @@ CREATE INDEX a_b_idx ON x (a,b) INCLUDE (c); #### Indexes on JSONB -Although a GIN/GIST index can be used to index entire JSONB bodies, you can also target just specific Key-values with standard BTREE indexes: +Although a GIN/GIST index can be used to index entire JSONB bodies, you can also target only specific Key-values with standard BTREE indexes: ```sql -- Example table diff --git a/apps/docs/content/troubleshooting/how-to-change-max-database-connections-_BQ8P5.mdx b/apps/docs/content/troubleshooting/how-to-change-max-database-connections-_BQ8P5.mdx index e1c69e3ee29..4682d7ed2dd 100644 --- a/apps/docs/content/troubleshooting/how-to-change-max-database-connections-_BQ8P5.mdx +++ b/apps/docs/content/troubleshooting/how-to-change-max-database-connections-_BQ8P5.mdx @@ -71,7 +71,7 @@ This is a Grafana Chart of unhealthy memory usage: ![image](/docs/img/troubleshooting/47685206-7914-440e-a010-da62f5c38186.png) - Yellow: represents active memory -- Red: represents SWAP, which is disk storage that the system treats as if it were actually memory +- Red: represents SWAP, which is disk storage that the system treats as if it were memory - Green: it is unclaimed (the system will always leave some memory unclaimed) - Blue: it is cached data and a buffer diff --git a/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx b/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx index 811abe5dc63..c0ece19c525 100644 --- a/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx +++ b/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx @@ -263,7 +263,7 @@ limit 100; When recording what is accessed and by whom, logging based on database roles and objects is the most reliable way to ensure a proper trail of activity. -You can use the [pg_audit](/docs/guides/database/extensions/pgaudit) extension to selectively log relevant queries (not just errors) by certain roles, against specific database objects. +You can use the [pg_audit](/docs/guides/database/extensions/pgaudit) extension to selectively log relevant queries, not only errors, by certain roles, against specific database objects. You should take care when using the extension to not log all database events, but only what is absolutely necessary. Over-logging can strain the database and create log noise that makes it difficult to filter for relevant events. diff --git a/apps/docs/content/troubleshooting/inserting-into-sequenceserial-table-causes-duplicate-key-violates-unique-constraint-error-pi6DnC.mdx b/apps/docs/content/troubleshooting/inserting-into-sequenceserial-table-causes-duplicate-key-violates-unique-constraint-error-pi6DnC.mdx index c09d6d28ae7..bb30ac7b402 100644 --- a/apps/docs/content/troubleshooting/inserting-into-sequenceserial-table-causes-duplicate-key-violates-unique-constraint-error-pi6DnC.mdx +++ b/apps/docs/content/troubleshooting/inserting-into-sequenceserial-table-causes-duplicate-key-violates-unique-constraint-error-pi6DnC.mdx @@ -28,7 +28,7 @@ SELECT nextval(pg_get_serial_sequence('', ' npx supabase secrets list ``` -For security reasons, it is not advised to log secrets, but you can log a truncated version just for the reassurance that they're being updated: +For security reasons, it is not advised to log secrets, but you can log a truncated version for the reassurance that they're being updated: ```typescript //logs the function call and the secrets diff --git a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx index bb66f23d10a..cdfc1ed59a0 100644 --- a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx +++ b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx @@ -64,7 +64,7 @@ Other useful Supabase Grafana guides: ### Esoteric factors **Webhooks**: -Supabase webhooks use the pg_net extension to handle requests. The `net.http_request_queue` table isn't indexed to keep write costs low. However, if you upload millions of rows to a webhook-enabled table too quickly, it can significantly increase the read costs for the extension. +Supabase webhooks use the pg_net extension to handle requests. The `net.http_request_queue` table isn't indexed to keep write costs low. However, if you upload millions of rows to a webhook-enabled table in rapid succession, it can significantly increase the read costs for the extension. To check if reads are becoming expensive, run: diff --git a/apps/docs/content/troubleshooting/keeping-free-projects-after-pro-upgrade-Kf9Xm2.mdx b/apps/docs/content/troubleshooting/keeping-free-projects-after-pro-upgrade-Kf9Xm2.mdx index 642457a9adc..1ded427c860 100644 --- a/apps/docs/content/troubleshooting/keeping-free-projects-after-pro-upgrade-Kf9Xm2.mdx +++ b/apps/docs/content/troubleshooting/keeping-free-projects-after-pro-upgrade-Kf9Xm2.mdx @@ -8,7 +8,7 @@ database_id = "449482fb-6e21-4a25-99ee-1ce6fffbc975" ## Can you keep your 2 free projects after upgrading to pro? -Yes! You still get 2 free projects after you upgrade to Pro. They just need to be in a **separate** Free Plan organization. +Yes. After upgrading to Pro, you still get 2 free projects. They must be in a separate Free Plan organization. ## Why do free projects need to be in a separate organization? diff --git a/apps/docs/content/troubleshooting/not-receiving-auth-emails-from-the-supabase-project-OFSNzw.mdx b/apps/docs/content/troubleshooting/not-receiving-auth-emails-from-the-supabase-project-OFSNzw.mdx index eb6a4880fce..6b5ae533118 100644 --- a/apps/docs/content/troubleshooting/not-receiving-auth-emails-from-the-supabase-project-OFSNzw.mdx +++ b/apps/docs/content/troubleshooting/not-receiving-auth-emails-from-the-supabase-project-OFSNzw.mdx @@ -9,7 +9,7 @@ database_id = "98e3b318-ad9a-41f5-ada0-44b61970ab4f" **We strongly recommend configuring your own custom SMTP provider** in your projects to avoid running into issues with sending emails from your project. The built-in email provider is for demonstration purposes only and offers a very low rate limit. You can read more details about the rate limits [here](/docs/guides/platform/going-into-prod#auth-rate-limits). -If you're testing your Supabase projects, consider configuring an email testing tool like [Mailtrap](https://mailtrap.io/) to test your emails. It's an email sandbox tool that helps debug emails without actually sending an email to an end user. +If you're testing your Supabase projects, consider configuring an email testing tool like [Mailtrap](https://mailtrap.io/) to test your emails. It's an email sandbox tool that helps debug emails without sending an email to an end user. As the initial step in debugging not-delivered emails, check your project's [Auth logs](/dashboard/project/_/logs/auth-logs) for errors that can occur when handing over to the email providers. In general, handover issues can occur due to misconfiguration of the custom SMTP provider. diff --git a/apps/docs/content/troubleshooting/pausing-pro-projects-vNL-2a.mdx b/apps/docs/content/troubleshooting/pausing-pro-projects-vNL-2a.mdx index f783efb49a7..0dbb540a781 100644 --- a/apps/docs/content/troubleshooting/pausing-pro-projects-vNL-2a.mdx +++ b/apps/docs/content/troubleshooting/pausing-pro-projects-vNL-2a.mdx @@ -14,7 +14,7 @@ Pro-Projects at the moment cannot be paused. However, [You are allowed to have t If a project is under 500MB, you can [transfer it to be under a free organization](/docs/guides/platform/project-transfer). Afterwards, you can initiate a pause. -Alternatively, you can download a [daily backup](/dashboard/project/_/database/backups/scheduled) of just your database for archiving. You can also manually download a .SQL file of your database and storage buckets by following this [guide](/docs/guides/platform/migrating-within-supabase/backup-restore). +Alternatively, you can download a [daily backup](/dashboard/project/_/database/backups/scheduled) of only your database for archiving. You can also manually download a .SQL file of your database and storage buckets by following this [guide](/docs/guides/platform/migrating-within-supabase/backup-restore). You can also download your storage buckets with the [Supabase CLI:](/docs/guides/cli/getting-started?queryGroups=platform&platform=npx) diff --git a/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx b/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx index 27d6f72b96a..e132244871a 100644 --- a/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx +++ b/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx @@ -17,7 +17,7 @@ The bug is triggered when an `or()` filter is included in a mutation request. Po ## How to confirm 1. **Reproduce the asymmetry** — run the same filter as a `GET` request. If it succeeds but the `PATCH`/`POST`/`DELETE` fails with the same column reference, the bug is the likely cause. -2. **Check your PostgREST version** — go to [Project Settings > Infrastructure](/dashboard/project/_/settings/infrastructure) and note the PostgreSQL version. PostgREST 14.1 and earlier are affected; 14.4+ includes the fix. +2. **Check your PostgREST version** — go to [Project Settings > Infrastructure](/dashboard/project/_/settings/infrastructure) and note the Postgres version. PostgREST 14.1 and earlier are affected; 14.4+ includes the fix. 3. **Check Postgres logs** — if you have logging enabled, you should see `column "example_column" does not exist` errors correlating with the timestamps of the failed mutation requests. ## Resolution diff --git a/apps/docs/content/troubleshooting/realtime-warn-sending-broadcast-message.mdx b/apps/docs/content/troubleshooting/realtime-warn-sending-broadcast-message.mdx index bbdf7013e0a..6188422ac4f 100644 --- a/apps/docs/content/troubleshooting/realtime-warn-sending-broadcast-message.mdx +++ b/apps/docs/content/troubleshooting/realtime-warn-sending-broadcast-message.mdx @@ -44,7 +44,7 @@ Calling `realtime.send` / `realtime.send_binary`, the broadcast REST endpoint, a ## How to avoid it -- Connect a client before broadcasting from the database. A live WebSocket connection both creates the partitions and starts the consumer that actually receives the message. +- Connect a client before broadcasting from the database. A live WebSocket connection both creates the partitions and starts the consumer that receives the message. - If you broadcast from the database on a schedule, make sure at least one subscriber is connected when you send. Otherwise the messages have no destination. ## When it is a real problem diff --git a/apps/docs/content/troubleshooting/resolving-42p01-relation-does-not-exist-error-W4_9-V.mdx b/apps/docs/content/troubleshooting/resolving-42p01-relation-does-not-exist-error-W4_9-V.mdx index 1d983d900bb..7954a056dfb 100644 --- a/apps/docs/content/troubleshooting/resolving-42p01-relation-does-not-exist-error-W4_9-V.mdx +++ b/apps/docs/content/troubleshooting/resolving-42p01-relation-does-not-exist-error-W4_9-V.mdx @@ -48,9 +48,9 @@ rename to table_name; --- -### Cause 3: Table or function actually does not exist +### Cause 3: Table or function does not exist -One may have never made the table or dropped it deliberately or accidentally. This can be quickly checked with the following query: +One may have never made the table or dropped it deliberately or accidentally. This can be checked with the following query: ```sql -- For tables diff --git a/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx b/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx index b9b532be608..344f13d04ab 100644 --- a/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx +++ b/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx @@ -106,7 +106,7 @@ Alternatively, you can run the SQL script in this [GitHub Gist](https://gist.git #### Trigger related: -If errors reference a database function, this indicates a trigger error on one of the auth tables (likely auth.users). If you do not want to keep the trigger/function, you can just quickly drop it, otherwise, continue reading to know how to fix the issue: +If errors reference a database function, this indicates a trigger error on one of the auth tables (likely auth.users). If you do not want to keep the trigger/function, you can drop it, otherwise, continue reading to know how to fix the issue: ```sql -- delete the trigger with the following SQL: diff --git a/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx b/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx index ec8820dff09..b1661baa9f7 100644 --- a/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx +++ b/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx @@ -11,7 +11,7 @@ database_id = "bb06ff88-e0a9-41bc-b00b-9afde10468a5" Although most of the time spent on thinking about RLS is to get it to handle security needs, the impact of it on performance of your queries can be massive. This is especially true on queries that look at every row in a table like for many select operations and updates. -Note that queries that use limit and offset will usually have to query all rows to determine order, not just the limit amount so they are impacted too. +Note that queries that use limit and offset will usually have to query all rows to determine order, not only the limit amount so they are impacted too. See the last section for ways to measure the performance of your queries as you test RLS improvements. @@ -107,7 +107,7 @@ Note that if the `in` list gets to be over 10K items, then extra analysis is lik #### 6. Use role in TO option or roles dropdown in the dashboard. -Never just use RLS involving `auth.uid()` or `auth.jwt()` as your way to rule out 'anon' role. +Never use RLS involving `auth.uid()` or `auth.jwt()` as your only way to rule out 'anon' role. Always add 'authenticated' to the approved roles instead of nothing or public. Although this does not improve the query performance for the signed in user it does eliminate 'anon' users without taxing the database to process the rest of the RLS. diff --git a/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx b/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx index 4d62b30e58d..d7b954f2de1 100644 --- a/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx +++ b/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx @@ -19,7 +19,7 @@ Once the JWT secret is regenerated, all current API secrets will be immediately
-Have you ever accidentally committed a service key to a public repo? Or maybe rotating keys is just something you regularly do for security compliance. +Have you ever accidentally committed a service key to a public repo? Or maybe rotating keys is something you regularly do for security compliance. Whatever the reason, here's how to rotate the keys for your Supabase project. If you haven’t migrated to asymmetric JWT signing keys: diff --git a/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx b/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx index 8aa25f3420a..35ec1fc84e9 100644 --- a/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx +++ b/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx @@ -8,7 +8,7 @@ database_id = "1d62cace-c0f6-47a0-8690-002a797da33b" sdk = [ "rpc" ] --- -Sometimes it can help to look at Postgres query plans inside a function. The problem is that running [`EXPLAIN ANALYZE`](https://www.depesz.com/2013/04/16/explaining-the-unexplainable/) on a function usually just shows a [function scan](https://pganalyze.com/docs/explain/scan-nodes/function-scan) or result node, which gives little insight into how the queries actually perform. +Sometimes it can help to look at Postgres query plans inside a function. The problem is that running [`EXPLAIN ANALYZE`](https://www.depesz.com/2013/04/16/explaining-the-unexplainable/) on a function usually shows a [function scan](https://pganalyze.com/docs/explain/scan-nodes/function-scan) or result node, which gives little insight into how the queries perform. [`auto_explain`](https://www.postgresql.org/docs/current/auto-explain.html) is a pre-installed module that is able to log query plans for queries within functions. diff --git a/apps/docs/content/troubleshooting/security-of-anonymous-sign-ins-iOrGCL.mdx b/apps/docs/content/troubleshooting/security-of-anonymous-sign-ins-iOrGCL.mdx index d524a22352c..ba1a957e518 100644 --- a/apps/docs/content/troubleshooting/security-of-anonymous-sign-ins-iOrGCL.mdx +++ b/apps/docs/content/troubleshooting/security-of-anonymous-sign-ins-iOrGCL.mdx @@ -13,7 +13,7 @@ We want to clarify and provide reassurance on this topic. Enabling anonymous sign-ins on your project does not reduce its security. Here's why: -- Same as Regular Users: Anonymous users function just like regular users within your project. They have unique user IDs and their own records in the authentication tables. +- Same as Regular Users: Anonymous users function like regular users within your project. They have unique user IDs and their own records in the authentication tables. - Security Policies: All role-based security policies (RLS) applicable to regular users also apply to anonymous users. - Identity Verification Measures: Even though anonymous users do not initially provide an email or phone number, the security of your project remains robust. But to prevent misuse, we recommend implementing additional security measure such as [CAPTCHA](/docs/guides/auth/auth-captcha): to ensure that interactions are genuinely human. diff --git a/apps/docs/content/troubleshooting/soft-deletes-with-supabase-js.mdx b/apps/docs/content/troubleshooting/soft-deletes-with-supabase-js.mdx index 915c6f9339d..21610977255 100644 --- a/apps/docs/content/troubleshooting/soft-deletes-with-supabase-js.mdx +++ b/apps/docs/content/troubleshooting/soft-deletes-with-supabase-js.mdx @@ -75,7 +75,7 @@ This effectively un-deletes the record. ## Benefits of using views for soft deletes -- **Cleaner Queries:** No need to add `WHERE deleted_at IS NULL` to every query. Just query the view (`active_items`). +- **Cleaner Queries:** No need to add `WHERE deleted_at IS NULL` to every query. Query the view (`active_items`) instead. - **Separation of Concerns:** Views abstract the logic of filtering deleted records from your application code. - **Efficiency:** Postgres handles the filtering in the view, reducing the complexity in your app. diff --git a/apps/docs/content/troubleshooting/steps-to-improve-query-performance-with-indexes-q8PoC9.mdx b/apps/docs/content/troubleshooting/steps-to-improve-query-performance-with-indexes-q8PoC9.mdx index d159b35512b..e724c1893a9 100644 --- a/apps/docs/content/troubleshooting/steps-to-improve-query-performance-with-indexes-q8PoC9.mdx +++ b/apps/docs/content/troubleshooting/steps-to-improve-query-performance-with-indexes-q8PoC9.mdx @@ -43,7 +43,7 @@ select from pg_statio_user_tables; ``` -If the cache hit rate is relatively low, it often means that you need to increase your memory capacity. The second metric that is often inspected is index usage. Indexes are data structures that allow Postgres to search for information quickly - think of them like you would think of an index at the back of a book. Instead of scanning every page (or row), you can use an index to find the contents you need quickly. For a better understanding of how Postgres decides on whether to use an index or not, check out this [explainer](https://github.com/orgs/supabase/discussions/26959). +If the cache hit rate is relatively low, it often means that you need to increase your memory capacity. The second metric that is often inspected is index usage. Indexes are data structures that allow Postgres to search for information efficiently - think of them like you would think of an index at the back of a book. Instead of scanning every page (or row), you can use an index to find the contents you need efficiently. For a better understanding of how Postgres decides on whether to use an index or not, check out this [explainer](https://github.com/orgs/supabase/discussions/26959). The index hit rate (how often an index is used) can usually be improved moderately. @@ -59,7 +59,7 @@ where seq_scan + idx_scan > 0 order by n_live_tup desc; ``` -A lot of the [queries for inspecting performance](/docs/reference/cli/supabase-inspect-db) are actually pre-bundled as part of the [Supabase CLI](/docs/guides/cli/getting-started). For instance, there is a command for testing which indexes of yours are unnecessary and are needlessly taking up space: +A lot of the [queries for inspecting performance](/docs/reference/cli/supabase-inspect-db) are pre-bundled as part of the [Supabase CLI](/docs/guides/cli/getting-started). For instance, there is a command for testing which indexes of yours are unnecessary and are needlessly taking up space: ```bash npx supabase login diff --git a/apps/docs/content/troubleshooting/storage-error-database-schema-is-incompatible-when-uploading-files-988baa.mdx b/apps/docs/content/troubleshooting/storage-error-database-schema-is-incompatible-when-uploading-files-988baa.mdx new file mode 100644 index 00000000000..8436a468a2c --- /dev/null +++ b/apps/docs/content/troubleshooting/storage-error-database-schema-is-incompatible-when-uploading-files-988baa.mdx @@ -0,0 +1,25 @@ +--- +title = "RLS policy causes infinite recursion" +date_created = "2026-06-29T03:04:33+00:00" +topics = [ "database", "storage" ] +keywords = ["infinite recursion", "RLS"] +--- + +If your Storage or database requests encounter an error and the corresponding Postgres logs show '_infinite recursion detected in policy for relation "table_name"_', it is because of infinite recursion in your Row-Level Security (RLS) policies. + +**Why Does This Happen?** + +This error indicates a circular dependency between RLS policies. It occurs when an RLS policy queries a table with RLS enabled, and that table's policies end up triggering the original policy again. + +This can happen when: + +- A policy queries the same table. +- A policy queries another table whose RLS policies eventually reference the original table, creating a circular dependency. + +Postgres detects the recursive policy evaluation and terminates the query to prevent infinite recursion. This error can also occur during Storage operations if a Storage RLS policy references a table with self-referencing or mutually recursive RLS policies. + +**How to Resolve This Issue:** + +Option 1: Update the table's RLS policies to ensure they do not directly or indirectly create a circular dependency. + +Option 2: Wrap the permission check in a SECURITY DEFINER function. This executes with the privileges of the user who created the function — if created with a role like postgres, it bypasses RLS on the target table and breaks the recursion. See [Use Security Definer Functions](/docs/guides/database/postgres/row-level-security#use-security-definer-functions) for details. diff --git a/apps/docs/content/troubleshooting/supabase-grafana-memory-charts.mdx b/apps/docs/content/troubleshooting/supabase-grafana-memory-charts.mdx index 3f7239eeeb5..4106f66cfba 100644 --- a/apps/docs/content/troubleshooting/supabase-grafana-memory-charts.mdx +++ b/apps/docs/content/troubleshooting/supabase-grafana-memory-charts.mdx @@ -14,7 +14,7 @@ Here are examples of unhealthy memory usage: ![image](https://github.com/supabase/supabase/assets/91111415/b95c83cf-aa98-4b50-8e07-29f57aaa676c) - **Yellow**: represents active memory -- **Red**: represents SWAP, which is disk storage that the system treats as if it were actually memory +- **Red**: represents SWAP, which is disk storage that the system treats as if it were memory - **Green**: it is unclaimed (the system will always leave some memory unclaimed) - **Blue**: it is cached data and a buffer diff --git a/apps/docs/content/troubleshooting/supavisor-and-connection-terminology-explained-9pr_ZO.mdx b/apps/docs/content/troubleshooting/supavisor-and-connection-terminology-explained-9pr_ZO.mdx index 04a60dc9c16..ad0f4472c28 100644 --- a/apps/docs/content/troubleshooting/supavisor-and-connection-terminology-explained-9pr_ZO.mdx +++ b/apps/docs/content/troubleshooting/supavisor-and-connection-terminology-explained-9pr_ZO.mdx @@ -7,7 +7,7 @@ keywords = [ "connections", "pooler", "transaction", "session", "pgbouncer" ] database_id = "e008e056-453d-44b5-8a8a-27c2d70681fe" --- -I'll be the first to admit that the official naming conventions in the Postgres community can be a bit confusing, so here's a basic rundown. It's a bit long, so feel free to just jump to the portion relevant to you: +I'll be the first to admit that the official naming conventions in the Postgres community can be a bit confusing, so here's a basic rundown. It's a bit long, so feel free to jump to the portion relevant to you: ## Clients: diff --git a/apps/docs/content/troubleshooting/supavisor-faq-YyP5tI.mdx b/apps/docs/content/troubleshooting/supavisor-faq-YyP5tI.mdx index 5680737403d..0bf72e502c8 100644 --- a/apps/docs/content/troubleshooting/supavisor-faq-YyP5tI.mdx +++ b/apps/docs/content/troubleshooting/supavisor-faq-YyP5tI.mdx @@ -81,11 +81,11 @@ When clients connect to either Postgres or Supavisor, they do so with the Postgr ### What are "client connections"? In summary, they have nothing to do with front-end clients. They are the amount of backend-server connections that can connect to a serverside pooler. -Imagine a chess tournament with 60 boards. Each board represents a connection in a database. When a player sits down at a board, it's like a client connecting to the database. They can take their time with their moves or just sit there, not making any. +Imagine a chess tournament with 60 boards. Each board represents a connection in a database. When a player sits down at a board, it's like a client connecting to the database. They can take their time with their moves or sit there, not making any. But when the tournament fills up and all the boards are taken, new players are turned away, and told to check back at a later time to see if a table becomes available. -Now, imagine the tournament organizers decide to expand the venue to house 200 people without adding more tables. Even when all boards are occupied, players don't have to leave. They can wait in the wings, and the moment a board opens up, someone from the waiting area can take their place. Likewise, if someone ends their game, but wants to play again, they can just go back to the waiting area. The waiting area represents the "Max Client Connections". Ultimately, the additional capacity provided by the pooler ensures fewer people are turned away from the "tournament". +Now, imagine the tournament organizers decide to expand the venue to house 200 people without adding more tables. Even when all boards are occupied, players don't have to leave. They can wait in the wings, and the moment a board opens up, someone from the waiting area can take their place. Likewise, if someone ends their game, but wants to play again, they can go back to the waiting area. The waiting area represents the "Max Client Connections". Ultimately, the additional capacity provided by the pooler ensures fewer people are turned away from the "tournament". ### In the context of Supavisor, what does "pool size" mean? @@ -93,14 +93,14 @@ Now, imagine the tournament organizers decide to expand the venue to house 200 p ### What is the "user+db+mode" combination? -Postgres is not actually a database. It is a Relational Database Management System (RDMS). Within it, you can spawn Postgres databases. In Supabase, it is a common pattern to just use the default database called `postgres`, but you could create more: +Postgres is not a database. It is a Relational Database Management System (RDMS). Within it, you can spawn Postgres databases. In Supabase, it is a common pattern to use the default database called `postgres`, but you could create more: ```sql CREATE DATABASE postgres; CREATE DATABASE another_database; ``` -Similarly, a database can have many database users, but most people just rely on the default user `postgres`. +Similarly, a database can have many database users, but most people rely on the default user `postgres`. ```sql CREATE USER postgres WITH PASSWORD 'super-secret-password'; diff --git a/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx b/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx index 325b647cac5..f679efeeaab 100644 --- a/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx +++ b/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx @@ -37,10 +37,10 @@ Notice that the log includes: - The full statement - The timestamp -- A `user` field, which is actually the Supabase user UUID +- A `user` field, which is the Supabase user UUID - The source (dashboard) -That UUID corresponds to the team member who logged in via the Supabase Dashboard and executed queries in the [SQL Editor](/dashboard/project/_/sql/new). But at this point, it's just an ID - not yet a name or email. +That UUID corresponds to the team member who logged in via the Supabase Dashboard and executed queries in the [SQL Editor](/dashboard/project/_/sql/new). But at this point, it's only an ID - not yet a name or email. ### **Mapping UUIDs to team members** diff --git a/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx b/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx index 45c7760eca4..97cbe55b88e 100644 --- a/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx +++ b/apps/docs/content/troubleshooting/understanding-postgresql-explain-output-Un9dqX.mdx @@ -45,9 +45,9 @@ const { data, error } = await supabase **- Seq Scan:** A sequential scan that reads all rows from a table. This is often seen in the absence of indexes that can be used for the query. -**- Index Scan:** Uses an index to find rows quickly. This indicates that the query is able to use an index to efficiently locate data. +**- Index Scan:** Uses an index to find rows efficiently. This indicates that the query is able to use an index to efficiently locate data. -**- Bitmap Heap Scan:** Uses a bitmap index to find rows quickly and then retrieves the actual rows from the table. This type of scan is efficient when retrieving a moderate number of rows. +**- Bitmap Heap Scan:** Uses a bitmap index to find rows efficiently and then retrieves the actual rows from the table. This type of scan is efficient when retrieving a moderate number of rows. ### 2. Cost @@ -70,7 +70,7 @@ const { data, error } = await supabase ### 6. Execution time -**Execution Time: 0.069 ms:** measures how long it took to actually execute the query, including retrieving the data, performing any sorts, joins, or other operations defined in the execution plan, and returning the final results. This time is measured in milliseconds. +**Execution Time: 0.069 ms:** measures how long it took to execute the query, including retrieving the data, performing any sorts, joins, or other operations defined in the execution plan, and returning the final results. This time is measured in milliseconds. ![image](/docs/img/troubleshooting/10950be3-264b-4a41-bc71-3d431c2756b2.png) @@ -78,7 +78,7 @@ const { data, error } = await supabase When running EXPLAIN ANALYZE, additional information is provided, including: -- **Actual time:** Shows the time actually spent executing the scan and retrieving rows. This is split into the time to retrieve the first row (first) and the time to retrieve all rows (last). +- **Actual time:** Shows the time spent executing the scan and retrieving rows. This is split into the time to retrieve the first row (first) and the time to retrieve all rows (last). - **Rows removed by filter:** Indicates how many rows were excluded due to not meeting the filter conditions. @@ -135,25 +135,25 @@ On top of that, you have to multiply the cost and the time with the number of ### Common nodes in Postgres explain output -| Node Type | Description | -| --------------------- | ---------------------------------------------------------------------------- | -| **Seq Scan** | Scans each row of a table sequentially, often used without suitable indexes. | -| **Index Scan** | Uses an index to quickly find rows, efficient for small fractions of rows. | -| **Index Only Scan** | Retrieves all needed data from the index itself, without visiting the table. | -| **Bitmap Heap Scan** | Uses a bitmap of row locations to efficiently retrieve rows from the table. | -| **Bitmap Index Scan** | Builds a bitmap by scanning the index to efficiently locate rows. | -| **`Tid` Scan** | Fetches rows directly using tuple identifiers, used in sub-selects. | -| **Nested Loop** | Joins two tables by scanning the first and then the second for each row. | -| **Merge Join** | Joins two pre-sorted tables, efficient for large datasets. | -| **Hash Join** | Uses a hash table to perform joins, often faster for larger datasets. | -| **Aggregate** | Performs aggregation calculations like `SUM`, `COUNT`, etc. | -| **Sort** | Sorts rows based on specified criteria, required for certain operations. | -| **Limit** | Returns a specified number of rows quickly, used with `LIMIT` clause. | -| **CTE Scan** | Scans a Common Table Expression, used for WITH clauses. | -| **Materialize** | Materializes the result of a subquery or node to reuse without re-running. | -| **Subquery Scan** | Executes and provides the results of a subquery to the outer query. | -| **Foreign Scan** | Fetches data from foreign data sources outside the local database. | -| **Function Scan** | Retrieves results from a set-returning function. | +| Node Type | Description | +| --------------------- | ------------------------------------------------------------------------------ | +| **Seq Scan** | Scans each row of a table sequentially, often used without suitable indexes. | +| **Index Scan** | Uses an index to efficiently find rows, efficient for small fractions of rows. | +| **Index Only Scan** | Retrieves all needed data from the index itself, without visiting the table. | +| **Bitmap Heap Scan** | Uses a bitmap of row locations to efficiently retrieve rows from the table. | +| **Bitmap Index Scan** | Builds a bitmap by scanning the index to efficiently locate rows. | +| **`Tid` Scan** | Fetches rows directly using tuple identifiers, used in sub-selects. | +| **Nested Loop** | Joins two tables by scanning the first and then the second for each row. | +| **Merge Join** | Joins two pre-sorted tables, efficient for large datasets. | +| **Hash Join** | Uses a hash table to perform joins, often faster for larger datasets. | +| **Aggregate** | Performs aggregation calculations like `SUM`, `COUNT`, etc. | +| **Sort** | Sorts rows based on specified criteria, required for certain operations. | +| **Limit** | Returns a specified number of rows efficiently, used with `LIMIT` clause. | +| **CTE Scan** | Scans a Common Table Expression, used for WITH clauses. | +| **Materialize** | Materializes the result of a subquery or node to reuse without re-running. | +| **Subquery Scan** | Executes and provides the results of a subquery to the outer query. | +| **Foreign Scan** | Fetches data from foreign data sources outside the local database. | +| **Function Scan** | Retrieves results from a set-returning function. | ### What to focus on in explain analyze output @@ -182,11 +182,11 @@ Determining whether 100 milliseconds for e.g is noteworthy in the context of ide **Overall Query Execution Time:** -If the total execution time of a query significantly exceeds 100 milliseconds, then this particular step may not represent the main bottleneck. For instance, in queries that take several seconds to execute, a component that consumes just 100 milliseconds may not be the critical target for optimization efforts. +If the total execution time of a query significantly exceeds 100 milliseconds, then this particular step may not represent the main bottleneck. For instance, in queries that take several seconds to execute, a component that consumes only 100 milliseconds may not be the critical target for optimization efforts. **Complexity and Scale of the Query:** -For complex queries that involve multiple joins, subqueries, or aggregation functions, an operation that takes 100 milliseconds might actually reflect good efficiency. Conversely, for simpler queries or operations expected to be quick (such as fetching a few rows from a well-indexed table), 100 milliseconds could suggest a lack of efficiency. +For complex queries that involve multiple joins, subqueries, or aggregation functions, an operation that takes 100 milliseconds might reflect good efficiency. Conversely, for simpler queries or operations expected to be quick (such as fetching a few rows from a well-indexed table), 100 milliseconds could suggest a lack of efficiency. So, the acceptable performance threshold can vary by application. For real-time systems or high-frequency trading platforms, even a few milliseconds can be critical, whereas for batch processing or data warehousing, longer execution times might be acceptable. diff --git a/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx b/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx index d162dd29360..a88e2e726b2 100644 --- a/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx +++ b/apps/docs/content/troubleshooting/understanding-postgresql-logging-levels-and-how-they-impact-your-project-KXiJRm.mdx @@ -7,7 +7,7 @@ keywords = [ "logging", "disk", "i/o", "lockups" ] database_id = "10186830-8cce-4f10-8cb9-7cbf39310763" --- -Since each Supabase project uses Postgres as its underlying database engine, it’s common to adjust logging settings for various reasons—whether for debugging issues, monitoring database performance, or auditing actions. However, modifying logging levels improperly can lead to an excessive amount of log data being generated, which can quickly fill up your disk space and cause significant performance degradation or even system failure. +Since each Supabase project uses Postgres as its underlying database engine, it’s common to adjust logging settings for various reasons—whether for debugging issues, monitoring database performance, or auditing actions. However, modifying logging levels improperly can lead to an excessive amount of log data being generated, which can fill up your disk space and cause significant performance degradation or even system failure. ### 1. Overview of Postgres logging levels @@ -41,7 +41,7 @@ The default log level is set to **WARNING** through the log_min_messages setting ### 2. How high log levels can affect your database -When users alter a high level of log settings, the database can start generating an overwhelming number of log entries. This can quickly escalate to issues such as: +When users alter a high level of log settings, the database can start generating an overwhelming number of log entries. This can escalate to issues such as: - Disk Space Exhaustion: Log files can grow exponentially if verbose levels like DEBUG, INFO, or NOTICE are enabled for long periods. And running out of disk space due to log bloat can cause your database to stop accepting writes and slow down query performance. diff --git a/apps/docs/content/troubleshooting/understanding-the-usage-summary-on-the-dashboard-D7Gnle.mdx b/apps/docs/content/troubleshooting/understanding-the-usage-summary-on-the-dashboard-D7Gnle.mdx index 6a49c389fe4..5457e9280aa 100644 --- a/apps/docs/content/troubleshooting/understanding-the-usage-summary-on-the-dashboard-D7Gnle.mdx +++ b/apps/docs/content/troubleshooting/understanding-the-usage-summary-on-the-dashboard-D7Gnle.mdx @@ -30,7 +30,7 @@ Using the database size as an example, suppose we have the following projects un If the user decides to delete Projects A and B during the current billing cycle, the average database usage will be 2 GB for the current billing cycle, even though only a 0.5 GB database is currently active. -As of now, even if your project was just active for a few days, we take the average database size for those active days, which may lead to high usage when creating and deleting many projects within a billing cycle. We're working on a better billing model to cover these cases. +As of now, even if your project was only active for a few days, we take the average database size for those active days, which may lead to high usage when creating and deleting many projects within a billing cycle. We're working on a better billing model to cover these cases. ## Edge Functions example diff --git a/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx b/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx index bfa806ef67f..bf32e5ee9b0 100644 --- a/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx +++ b/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx @@ -30,7 +30,7 @@ Otherwise, it is necessary to fast reboot your instance in the [Dashboard's Sett Using `pg_net` in triggers on most tables is fine; however, do not add triggers to the `net._http_response` or `net.http_request_queue` tables. -The `net` tables are special and if triggers on them fail or call a pg_net function (`http_get`, `http_post`, `http_delete`), it can lead to an infinite loop. This warning is irrelevant to most projects, but it's worth specifying just in case. +The `net` tables are special and if triggers on them fail or call a pg_net function (`http_get`, `http_post`, `http_delete`), it can lead to an infinite loop. This warning is irrelevant to most projects, but it's worth specifying in case. **3. Check for timeout errors** @@ -56,7 +56,7 @@ body := '{"key1": "value", "key2": 5}'::jsonb ) as request_id; ``` -Postman will then respond with the same payload. This is just a test to confirm that requests are being properly formatted and going through. +Postman will then respond with the same payload. This is a test to confirm that requests are being properly formatted and going through. You can then view the request in the `net._http_response table` in the [Table Editor](/dashboard/project/_/editor) or with the following SQL: diff --git a/apps/docs/content/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx.mdx b/apps/docs/content/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx.mdx index 6191835cbca..5e2c4be8278 100644 --- a/apps/docs/content/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx.mdx +++ b/apps/docs/content/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx.mdx @@ -9,9 +9,9 @@ database_id = "6fa68831-6db8-4836-a1b4-e9e51296d529" Usually this means you have RLS (row level security) enabled and no policy, or do not meet the policy. It can also mean you have a filter and have no rows matching that. -If you have RLS enabled you can test quickly by disable RLS on the table. If your query works then you have no policies or do not meet them. +If you have RLS enabled you can test by disabling RLS on the table. If your query works then you have no policies or do not meet them. -If you have policies that depend on having a signed in user (TO set to `authenticated` or using `auth.uid()`) then you can check by setting TO as `anon` and setting your policy to just `TRUE`. If that works then you don't have a signed in user (with a JWT in the authorization header) when you make the call. +If you have policies that depend on having a signed in user (TO set to `authenticated` or using `auth.uid()`) then you can check by setting TO as `anon` and setting your policy to `TRUE`. If that works then you don't have a signed in user (with a JWT in the authorization header) when you make the call. For more information on RLS see https://supabase.com/docs/guides/auth/row-level-security diff --git a/apps/docs/data/content-listings/auth.data.ts b/apps/docs/data/content-listings/auth.data.ts new file mode 100644 index 00000000000..e70d1cd5836 --- /dev/null +++ b/apps/docs/data/content-listings/auth.data.ts @@ -0,0 +1,102 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const authGetStarted: ContentListingGroup = { + id: 'auth-get-started', + heading: 'Get started', + description: "Start here if you're new to Supabase Auth:", + type: 'grid', + items: [ + { + title: 'Auth with email and password', + href: '/guides/auth/passwords', + description: 'Sign up and sign in users with email and password.', + }, + { + title: 'Server-side rendering', + href: '/guides/auth/server-side', + description: 'Create a Supabase client for SSR frameworks like Next.js and SvelteKit.', + }, + { + title: 'Row Level Security', + href: '/guides/database/postgres/row-level-security', + description: 'Use RLS policies to authorize data access from the client.', + }, + ], +} + +export const authPricing: ContentListingGroup = { + id: 'auth-pricing', + heading: 'Pricing', + description: + 'Charges apply to Monthly Active Users (MAU), Monthly Active Third-Party Users (Third-Party MAU), and Monthly Active SSO Users (SSO MAU) and Advanced MFA Add-ons. For a detailed breakdown of how these charges are calculated, refer to the following pages.', + type: 'list', + items: [ + { + title: 'Pricing MAU', + href: '/guides/platform/manage-your-usage/monthly-active-users', + description: 'How MAU usage is measured and billed.', + }, + { + title: 'Pricing Third-Party MAU', + href: '/guides/platform/manage-your-usage/monthly-active-users-third-party', + description: 'How third-party auth MAU is measured and billed.', + }, + { + title: 'Pricing SSO MAU', + href: '/guides/platform/manage-your-usage/monthly-active-users-sso', + description: 'How SSO MAU usage is measured and billed.', + }, + { + title: 'Advanced MFA - Phone', + href: '/guides/platform/manage-your-usage/advanced-mfa-phone', + description: 'How Advanced MFA Phone add-on usage is measured and billed.', + }, + ], +} + +export const authNextSteps: ContentListingGroup = { + id: 'auth-next-steps', + heading: 'Next steps', + description: + "Once you've covered the basics, these guides help with other use cases and features:", + type: 'grid', + columns: 4, + items: [ + { + title: 'Email (Magic link or OTP)', + href: '/guides/auth/auth-email-passwordless', + description: + 'Sign up and sign in users with a Magic Link or email OTP instead of a password.', + }, + { + title: 'Enterprise SSO', + href: '/guides/auth/enterprise-sso', + description: 'Add Single Sign-On for enterprise applications with SAML 2.0.', + }, + { + title: 'User sessions', + href: '/guides/auth/sessions', + description: 'Control session lifetime, refresh tokens, and multi-device sign-in behavior.', + }, + { + title: 'Third-party auth', + href: '/guides/auth/third-party/overview', + description: 'Use Clerk, Auth0, Firebase Auth, Cognito, or WorkOS JWTs with Supabase APIs.', + }, + { + title: 'Multi-factor authentication', + href: '/guides/auth/auth-mfa', + description: 'Add a second factor to user sign-in with TOTP or phone.', + }, + { + title: 'JWTs', + href: '/guides/auth/jwts', + description: 'Understand how Supabase Auth issues and validates JWTs.', + }, + { + title: 'Auth Hooks', + href: '/guides/auth/auth-hooks', + description: 'Customize Auth behavior with Postgres functions at key lifecycle points.', + }, + ], +} diff --git a/apps/docs/data/content-listings/database.data.ts b/apps/docs/data/content-listings/database.data.ts new file mode 100644 index 00000000000..32961aed8fb --- /dev/null +++ b/apps/docs/data/content-listings/database.data.ts @@ -0,0 +1,90 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const databaseGetStarted: ContentListingGroup = { + id: 'database-get-started', + heading: 'Get started', + description: "If you're new to the database section, these are the pages to read first:", + type: 'grid', + columns: 2, + items: [ + { + title: 'Connect to your database', + href: '/guides/database/connecting-to-postgres', + description: + 'Connection strings, the Supavisor connection pooler, and when to use direct, transaction, or session mode.', + }, + { + title: 'Tables and data', + href: '/guides/database/tables', + description: 'Create tables and relationships, and edit rows from the Dashboard.', + }, + { + title: 'Import data', + href: '/guides/database/import-data', + description: 'Load existing data from CSV files, `pg_dump`, or another Postgres database.', + }, + { + title: 'Secure your data', + href: '/guides/database/secure-data', + description: + 'Row Level Security (RLS) is how Supabase makes the database safe to query directly from the client. Read this before exposing any table to your app.', + }, + { + title: 'Extensions', + href: '/guides/database/extensions', + description: + 'Add Postgres extensions from the Dashboard, including `pgvector` for embeddings, `PostGIS` for geospatial data, and `pg_cron` for scheduled jobs.', + }, + { + title: 'Run SQL commands', + href: '/dashboard/project/_/sql', + description: "Use the Dashboard's SQL Editor for ad-hoc queries and saved snippets.", + }, + ], +} + +export const databaseNextSteps: ContentListingGroup = { + id: 'database-next-steps', + heading: 'Next steps', + description: + "Once you've covered the basics, these guides help with other use cases and features:", + type: 'grid', + items: [ + { + title: 'Database functions', + href: '/guides/database/functions', + description: 'Run logic inside the database in response to inserts, updates, or deletes.', + }, + { + title: 'Triggers', + href: '/guides/database/postgres/triggers', + description: 'Run logic inside the database in response to inserts, updates, or deletes.', + }, + { + title: 'Database webhooks', + href: '/guides/database/webhooks', + description: 'Send row changes to an external HTTP endpoint.', + }, + { + title: 'Replication and read replicas', + href: '/guides/database/replication', + description: 'Stream changes to other systems or read from a geographically closer replica.', + }, + { + title: 'Backups', + href: '/guides/platform/backups', + description: + 'Daily backups on every project, with point-in-time recovery on paid plans. Backups cover the database itself; objects stored through the Storage API are not included.', + }, + { + title: 'Query performance and optimization', + href: '/guides/database/query-optimization', + description: 'Indexes, the query planner, and tools for finding slow queries.', + }, + { + title: 'Roles and permissions', + href: '/guides/database/postgres/roles', + description: 'The Postgres roles Supabase ships with and how to add your own.', + }, + ], +} diff --git a/apps/docs/data/content-listings/functions.data.ts b/apps/docs/data/content-listings/functions.data.ts new file mode 100644 index 00000000000..bb69d870704 --- /dev/null +++ b/apps/docs/data/content-listings/functions.data.ts @@ -0,0 +1,211 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const functionsGetStarted: ContentListingGroup = { + id: 'functions-get-started', + heading: 'Get started', + type: 'grid', + columns: 2, + items: [ + { + title: 'Edge Functions quickstart', + href: '/guides/functions/quickstart', + description: 'Create, test, and deploy your first Edge Function with the Supabase CLI.', + }, + ], +} + +export const functionsExamplesSupabase: ContentListingGroup = { + id: 'functions-examples-supabase', + heading: 'Supabase integration', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'With supabase-js', + href: '/guides/functions/auth', + description: 'Use the Supabase client inside your Edge Function.', + }, + { + title: 'Connect to Postgres', + href: '/guides/functions/connect-to-postgres', + description: 'Connect to Postgres from Edge Functions.', + }, + { + title: 'Type-Safe SQL with Kysely', + href: '/guides/functions/kysely-postgres', + description: + 'Combine Kysely with Deno Postgres for a convenient developer experience when interacting directly with your Postgres database.', + }, + { + title: 'With CORS headers', + href: '/guides/functions/cors', + description: 'Send CORS headers for invoking from the browser.', + }, + { + title: 'Building a RESTful Service API', + href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/restful-tasks/index.ts', + icon: '/docs/img/icons/github-icon', + description: + 'Learn how to use HTTP methods and paths to build a RESTful service for managing tasks.', + }, + { + title: 'Oak Server Middleware', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/oak-server', + icon: '/docs/img/icons/github-icon', + description: 'Route requests with Oak server middleware.', + }, + { + title: 'Web Stream', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/streams', + icon: '/docs/img/icons/github-icon', + description: 'Stream Server-Sent Events from Edge Functions.', + }, + { + title: 'Get User Location', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/location', + icon: '/docs/img/icons/github-icon', + description: "Get user location data from user's IP address.", + }, + { + title: 'Working with Supabase Storage', + href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/read-storage/index.ts', + icon: '/docs/img/icons/github-icon', + description: 'Read a file from Supabase Storage.', + }, + { + title: 'Upload File', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/file-upload-storage', + icon: '/docs/img/icons/github-icon', + description: 'Process multipart/form-data.', + }, + ], +} + +export const functionsExamplesWebhooksPayments: ContentListingGroup = { + id: 'functions-examples-webhooks-payments', + heading: 'Webhooks & payments', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Stripe Webhooks', + href: '/guides/functions/examples/stripe-webhooks', + description: 'Handle signed Stripe webhooks with Edge Functions.', + }, + { + title: 'React Native with Stripe', + href: 'https://github.com/supabase-community/expo-stripe-payments-with-supabase-functions', + icon: '/docs/img/icons/github-icon', + description: 'Use Supabase and Stripe in a React Native app with Expo.', + }, + { + title: 'Flutter with Stripe', + href: 'https://github.com/supabase-community/flutter-stripe-payments-with-supabase-functions', + icon: '/docs/img/icons/github-icon', + description: 'Use Supabase and Stripe in a Flutter app.', + }, + ], +} + +export const functionsExamplesAiMedia: ContentListingGroup = { + id: 'functions-examples-ai-media', + heading: 'AI & media', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Hugging Face', + href: '/guides/ai/examples/huggingface-image-captioning', + description: 'Access 100,000+ Machine Learning models.', + }, + { + title: 'OpenAI', + href: '/guides/ai/examples/openai', + description: 'Use OpenAI in Edge Functions.', + }, + { + title: 'Amazon Bedrock', + href: '/guides/functions/examples/amazon-bedrock-image-generator', + description: 'Generate images with Amazon Bedrock in Edge Functions.', + }, + { + title: 'Open Graph Image Generation', + href: '/guides/functions/examples/og-image', + description: 'Generate Open Graph images with Deno and Supabase Edge Functions.', + }, + { + title: 'OG Image Generation & Storage CDN Caching', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/og-image-with-storage-cdn', + icon: '/docs/img/icons/github-icon', + description: 'Cache generated images with Supabase Storage CDN.', + }, + { + title: 'Puppeteer', + href: '/guides/functions/examples/screenshots', + description: 'Generate screenshots with Puppeteer.', + }, + ], +} + +export const functionsExamplesMessaging: ContentListingGroup = { + id: 'functions-examples-messaging', + heading: 'Bots & email', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Send emails', + href: '/guides/functions/examples/send-emails', + description: 'Send emails in Edge Functions with Resend.', + }, + { + title: 'Discord Bot', + href: '/guides/functions/examples/discord-bot', + description: 'Build a slash command Discord bot with Edge Functions.', + }, + { + title: 'Telegram Bot', + href: '/guides/functions/examples/telegram-bot', + description: 'Build a Telegram bot with Edge Functions.', + }, + { + title: 'Slack Bot Mention Edge Function', + href: '/guides/functions/examples/slack-bot-mention', + description: 'Handle Slack mentions in a Slack bot Edge Function.', + }, + ], +} + +export const functionsExamplesOperations: ContentListingGroup = { + id: 'functions-examples-operations', + heading: 'Operations & security', + headingLevel: 'h3', + type: 'grid', + items: [ + { + title: 'Monitoring with Sentry', + href: '/guides/functions/examples/sentry-monitoring', + description: 'Monitor Edge Functions with the Sentry Deno SDK.', + }, + { + title: 'GitHub Actions', + href: '/guides/functions/examples/github-actions', + description: 'Deploy Edge Functions with GitHub Actions.', + }, + { + title: 'Upstash Redis', + href: '/guides/functions/examples/upstash-redis', + description: 'Build an Edge Functions Counter with Upstash Redis.', + }, + { + title: 'Rate Limiting', + href: '/guides/functions/examples/rate-limiting', + description: 'Rate-limit Edge Functions with Upstash Redis.', + }, + { + title: 'Cloudflare Turnstile', + href: '/guides/functions/examples/cloudflare-turnstile', + description: 'Protect forms with Cloudflare Turnstile.', + }, + ], +} diff --git a/apps/docs/data/content-listings/getting-started.data.ts b/apps/docs/data/content-listings/getting-started.data.ts new file mode 100644 index 00000000000..33d36e08e8b --- /dev/null +++ b/apps/docs/data/content-listings/getting-started.data.ts @@ -0,0 +1,23 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const gettingStartedGetStarted: ContentListingGroup = { + id: 'getting-started-overview', + type: 'grid', + items: [ + { + title: 'Build with AI tools', + href: '/guides/ai-tools', + description: 'Develop with Supabase AI-first using plugins, MCP, and skills.', + }, + { + title: 'API Keys', + href: '/guides/getting-started/api-keys', + description: 'Learn about the different API keys in Supabase and how to use them.', + }, + { + title: 'Local Development', + href: '/guides/local-development', + description: 'Use the Supabase CLI to develop locally and collaborate between teams.', + }, + ], +} diff --git a/apps/docs/data/content-listings/index.ts b/apps/docs/data/content-listings/index.ts new file mode 100644 index 00000000000..b5ac8a84d09 --- /dev/null +++ b/apps/docs/data/content-listings/index.ts @@ -0,0 +1,40 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +import { authGetStarted, authNextSteps, authPricing } from './auth.data' +import { databaseGetStarted, databaseNextSteps } from './database.data' +import { + functionsExamplesAiMedia, + functionsExamplesMessaging, + functionsExamplesOperations, + functionsExamplesSupabase, + functionsExamplesWebhooksPayments, + functionsGetStarted, +} from './functions.data' +import { gettingStartedGetStarted } from './getting-started.data' +import { realtimeExamples, realtimeGetStarted, realtimeResources } from './realtime.data' +import { storageExamples, storageGetStarted, storageResources } from './storage.data' + +const ALL_GROUPS: readonly ContentListingGroup[] = [ + authGetStarted, + authPricing, + authNextSteps, + databaseGetStarted, + databaseNextSteps, + functionsGetStarted, + functionsExamplesSupabase, + functionsExamplesWebhooksPayments, + functionsExamplesAiMedia, + functionsExamplesMessaging, + functionsExamplesOperations, + gettingStartedGetStarted, + realtimeGetStarted, + realtimeExamples, + realtimeResources, + storageGetStarted, + storageExamples, + storageResources, +] + +export const CONTENT_LISTINGS: Readonly> = Object.fromEntries( + ALL_GROUPS.map((group) => [group.id, group]) +) diff --git a/apps/docs/data/content-listings/realtime.data.ts b/apps/docs/data/content-listings/realtime.data.ts new file mode 100644 index 00000000000..0e0e85b2270 --- /dev/null +++ b/apps/docs/data/content-listings/realtime.data.ts @@ -0,0 +1,66 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const realtimeGetStarted: ContentListingGroup = { + id: 'realtime-get-started', + heading: 'Get started', + type: 'grid', + columns: 2, + items: [ + { + title: 'Getting Started', + href: '/guides/realtime/getting_started', + description: 'Set up Realtime in your project and send your first message.', + }, + ], +} + +export const realtimeExamples: ContentListingGroup = { + id: 'realtime-examples', + heading: 'Examples', + type: 'grid', + columns: 2, + items: [ + { + title: 'Multiplayer.dev', + href: 'https://multiplayer.dev', + description: + 'Showcase application displaying cursor movements and chat messages using Broadcast.', + }, + { + title: 'Chat', + href: 'https://supabase.com/ui/docs/nextjs/realtime-chat', + description: 'Supabase UI chat component using Broadcast to send message between users.', + }, + { + title: 'Avatar Stack', + href: 'https://supabase.com/ui/docs/nextjs/realtime-avatar-stack', + description: 'Supabase UI avatar stack component using Presence to track connected users.', + }, + { + title: 'Realtime Cursor', + href: 'https://supabase.com/ui/docs/nextjs/realtime-cursor', + description: + "Supabase UI realtime cursor component using Broadcast to share users' cursors to build collaborative applications.", + }, + ], +} + +export const realtimeResources: ContentListingGroup = { + id: 'realtime-resources', + heading: 'Resources', + description: 'Find the source code and documentation in the Supabase GitHub repository:', + type: 'grid', + columns: 2, + items: [ + { + title: 'Supabase Realtime', + href: 'https://github.com/supabase/realtime', + description: 'View the source code.', + }, + { + title: 'Realtime: Multiplayer Edition', + href: 'https://supabase.com/blog/supabase-realtime-multiplayer-general-availability', + description: 'Read more about Supabase Realtime.', + }, + ], +} diff --git a/apps/docs/data/content-listings/storage.data.ts b/apps/docs/data/content-listings/storage.data.ts new file mode 100644 index 00000000000..9180ea08d49 --- /dev/null +++ b/apps/docs/data/content-listings/storage.data.ts @@ -0,0 +1,72 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const storageGetStarted: ContentListingGroup = { + id: 'storage-get-started', + heading: 'Get started', + description: 'Choose the bucket type that fits your use case:', + type: 'grid', + items: [ + { + title: 'Files buckets', + href: '/guides/storage/quickstart', + description: + 'Store and serve images, videos, documents, and general-purpose files with direct URL access and row-level security.', + }, + { + title: 'Analytics buckets', + href: '/guides/storage/analytics/introduction', + description: + 'Store data in Apache Iceberg tables for data lakes, logs, and Supabase Pipelines. Query from Postgres via foreign tables with partitioning.', + }, + { + title: 'Vector buckets', + href: '/guides/storage/vector/introduction', + description: + 'Store embeddings and run similarity search for semantic matching, AI, and RAG. Use HNSW indexing, distance metrics, and metadata filtering.', + }, + ], +} + +export const storageExamples: ContentListingGroup = { + id: 'storage-examples', + heading: 'Examples', + description: 'Working sample projects for common Storage integration patterns:', + type: 'grid', + columns: 2, + items: [ + { + title: 'Storage templates and examples', + href: 'https://github.com/supabase/supabase/tree/master/examples/storage', + description: + 'Sample projects for resumable uploads, signed upload URLs, and serving map tiles from private buckets.', + }, + { + title: 'Resumable Uploads with Uppy', + href: 'https://github.com/supabase/supabase/tree/master/examples/storage/resumable-upload-uppy', + icon: '/docs/img/icons/github-icon', + description: + 'Upload large files with pause-and-resume support using Uppy and the TUS protocol.', + }, + ], +} + +export const storageResources: ContentListingGroup = { + id: 'storage-resources', + heading: 'Resources', + description: 'Source code and REST API reference for the Storage service:', + type: 'grid', + columns: 2, + items: [ + { + title: 'Supabase Storage API', + href: 'https://github.com/supabase/storage-api', + description: 'Amazon S3-compatible object storage service that stores metadata in Postgres.', + }, + { + title: 'OpenAPI Spec', + href: 'https://supabase.github.io/storage/', + description: + 'Interactive reference for Storage REST endpoints, request parameters, and response schemas.', + }, + ], +} diff --git a/apps/docs/features/docs/GuidesMdx.client.tsx b/apps/docs/features/docs/GuidesMdx.client.tsx index 546e4342e01..2ab13db5fb4 100644 --- a/apps/docs/features/docs/GuidesMdx.client.tsx +++ b/apps/docs/features/docs/GuidesMdx.client.tsx @@ -1,7 +1,8 @@ 'use client' import { useEffect, useState, type PropsWithChildren } from 'react' -import { AnchorProvider } from 'ui-patterns' +import { AnchorProvider } from 'ui-patterns/Toc' + import { TocAnchorsContext, useSubscribeTocRerender } from './GuidesMdx.state' interface TOCHeader { diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index c3d5a5e2c3c..d46e6636c10 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -101,9 +101,9 @@ const GuideTemplate = ({ {meta?.title || 'Supabase Docs'} {meta?.subtitle && ( -

+
{meta.subtitle} -

+ )}
diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index b47ebf114e8..9df8413a927 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -5,6 +5,7 @@ import AuthProviders from '~/components/AuthProviders' import { AuthSmsProviderConfig } from '~/components/AuthSmsProviderConfig' import ButtonCard from '~/components/ButtonCard' import { ComputeDiskLimitsTable } from '~/components/ComputeDiskLimitsTable' +import { ContentListings } from '~/components/ContentListings' import { Extensions } from '~/components/Extensions' import Image, { type ImageProps } from '~/components/Image' import { Mermaid } from '~/components/Mermaid' @@ -26,6 +27,7 @@ import { ShowUntil } from '~/features/ui/ShowUntil' import { TabPanel, Tabs } from '~/features/ui/Tabs' import { ArrowDown, Check, X } from 'lucide-react' import Link from 'next/link' +import { type ComponentPropsWithoutRef } from 'react' import { Badge, Button } from 'ui' import { Admonition, type AdmonitionProps } from 'ui-patterns/admonition' import { GlassPanel } from 'ui-patterns/GlassPanel' @@ -74,6 +76,7 @@ const components = { CodeSampleDummy, CodeSampleWrapper, ComputeDiskLimitsTable, + ContentListings, ErrorCodes, Extensions, GlassPanel, @@ -100,17 +103,17 @@ const components = { TabPanel, InfoTooltip, a: MdxAnchor, - h2: (props: any) => ( + h2: (props: ComponentPropsWithoutRef<'h2'>) => ( {props.children} ), - h3: (props: any) => ( + h3: (props: ComponentPropsWithoutRef<'h3'>) => ( {props.children} ), - h4: (props: any) => ( + h4: (props: ComponentPropsWithoutRef<'h4'>) => ( {props.children} diff --git a/apps/docs/features/docs/Reference.constants.ts b/apps/docs/features/docs/Reference.constants.ts index f9133fa6cab..71daf6b8fe1 100644 --- a/apps/docs/features/docs/Reference.constants.ts +++ b/apps/docs/features/docs/Reference.constants.ts @@ -16,4 +16,4 @@ * not listed here keeps reading from the legacy `features/docs/generated/` * outputs. */ -export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set(['javascript-v2']) +export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set(['javascript-v2', 'dart-v2']) diff --git a/apps/docs/features/docs/Troubleshooting.ui.client.tsx b/apps/docs/features/docs/Troubleshooting.ui.client.tsx index 6d1eace163e..c64191fd98f 100644 --- a/apps/docs/features/docs/Troubleshooting.ui.client.tsx +++ b/apps/docs/features/docs/Troubleshooting.ui.client.tsx @@ -6,7 +6,7 @@ import { useQueryStates } from 'nuqs' import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button_Shadcn_, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, Input } from 'ui' import { MultiSelector } from 'ui-patterns/multi-select' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { type ITroubleshootingMetadata } from './Troubleshooting.utils' import { diff --git a/apps/docs/features/ui/McpConfigPanel.tsx b/apps/docs/features/ui/McpConfigPanel.tsx index d8823dc2bc3..a02e50443c0 100644 --- a/apps/docs/features/ui/McpConfigPanel.tsx +++ b/apps/docs/features/ui/McpConfigPanel.tsx @@ -22,13 +22,13 @@ import { PopoverTrigger, ScrollArea, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { createMcpCopyHandler, McpConfigPanel as McpConfigPanelBase, type McpClient, } from 'ui-patterns/McpUrlBuilder' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' type PlatformType = (typeof PLATFORMS)[number]['value'] diff --git a/apps/docs/features/ui/Tabs.tsx b/apps/docs/features/ui/Tabs.tsx index f0f3f8529d8..7f839928083 100644 --- a/apps/docs/features/ui/Tabs.tsx +++ b/apps/docs/features/ui/Tabs.tsx @@ -1,43 +1,273 @@ 'use client' -import { useCallback, type ComponentPropsWithoutRef, type PropsWithChildren } from 'react' -import { Tabs as TabsPrimitive, type TabsProps } from 'ui' -import { withQueryParams, withSticky, type QueryParamsProps } from 'ui-patterns/ComplexTabs' -import { useTocRerenderTrigger } from '~/features/docs/GuidesMdx.state' +import { cva } from 'class-variance-authority' +import { Tabs as TabsPrimitive } from 'radix-ui' +import { + Children, + isValidElement, + ReactElement, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, + type KeyboardEvent, +} from 'react' +import { cn } from 'ui' -const TabsWithStickyAndQueryParams = withSticky>( - withQueryParams(TabsPrimitive) +import { useTocRerenderTrigger } from '../docs/GuidesMdx.state' +import { useStickyTabs, UseStickyTabsOptions } from './useStickyTabs' +import { useTabsWithQueryParams, UseTabsWithQueryParamsOptions } from './useTabsWithQueryParams' + +export interface TabsProps { + children: ReactNode + type?: 'pills' | 'underlined' | 'cards' | 'rounded-pills' + defaultActiveId?: string + activeId?: string + size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' + block?: boolean + onChange?: (id: string) => void + onClick?: any + scrollable?: boolean + wrappable?: boolean + addOnBefore?: React.ReactNode + addOnAfter?: React.ReactNode + listClassNames?: string + baseClassNames?: string +} + +export const tabsListVariants = cva(cn('flex'), { + variants: { + type: { + pills: 'space-x-1', + underlined: 'items-center border-b border-secondary', + cards: '', + 'rounded-pills': 'flex-wrap gap-2', + }, + scrollable: { + true: 'overflow-auto whitespace-nowrap no-scrollbar mask-fadeout-right', + }, + wrappable: { + true: 'flex-wrap', + }, + }, +}) + +export const tabsTriggerListVariants = cva( + cn( + 'relative cursor-pointer flex items-center space-x-2 text-center transition focus:outline-hidden focus-visible:ring-3 focus-visible:ring-foreground-muted focus-visible:border-foreground-muted' + ), + { + variants: { + type: { + pills: 'shadow-xs rounded-sm border', + underlined: 'text-foreground-lighter', + cards: '', + 'rounded-pills': 'shadow-xs rounded-full', + }, + size: { + tiny: 'text-xs px-2.5 py-1', + small: 'text-base md:text-sm leading-4 px-3 py-2', + medium: 'text-base md:text-sm px-4 py-2', + large: 'text-base px-4 py-2', + xlarge: 'text-base px-6 py-3', + }, + block: { + true: 'w-full flex items-center justify-center', + }, + isActive: { + false: 'hover:text-foreground', + }, + }, + compoundVariants: [ + { + type: 'pills', + isActive: true, + className: 'bg-selection text-foreground border-stronger', + }, + { + type: 'pills', + isActive: false, + className: + 'bg-background border-strong hover:border-foreground-muted text-foreground-muted', + }, + { + type: 'underlined', + isActive: true, + className: '!text-foreground border-b-2 border-foreground', + }, + { + type: 'rounded-pills', + isActive: true, + className: 'bg-foreground text-background border-foreground', + }, + { + type: 'rounded-pills', + isActive: false, + className: + 'bg-surface-200 hover:bg-surface-300 hover:border-foreground-lighter text-foreground-lighter', + }, + ], + } ) -const TabPanel = TabsPrimitive.Panel -const Tabs = ({ +const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string' + +export const Tabs = ({ + defaultActiveId, + activeId, + type = 'pills', + size = 'tiny', + block, onChange, + onClick, + scrollable, + wrappable, + addOnBefore, + addOnAfter, + listClassNames, + baseClassNames, + children, + queryGroup, stickyTabList, - ...props -}: ComponentPropsWithoutRef) => { +}: TabsProps & + Pick & { + stickyTabList?: UseStickyTabsOptions + }) => { + const childrenArr: ReactElement[] = [] + const tabIds: string[] = [] + Children.forEach(children, (child) => { + if (isValidElement(child) && isString(child.props.id)) { + childrenArr.push(child) + tabIds.push(child.props.id) + } + }) + const { queryTab, onTabSelected: onTabSelectedForQuery } = useTabsWithQueryParams({ + tabIds, + queryGroup, + }) + + const sanitizedStickyTabList = useMemo( + () => + stickyTabList != undefined + ? { + ...stickyTabList, + // Magic number is the height of tab list + paragraph margin, worth getting + // rid of this? + scrollMarginTop: + stickyTabList.scrollMarginTop || 'calc(var(--header-height) + 43px + 20px)', + } + : undefined, + [stickyTabList] + ) + const { + observedRef, + stickyRef, + onTabSelected: onTabSelectedForSticky, + } = useStickyTabs(sanitizedStickyTabList) const rerenderToc = useTocRerenderTrigger() - const onChangeInternal = useCallback( - (...args: Parameters) => { - rerenderToc() - onChange?.(...args) - }, - [rerenderToc, onChange] + + const [activeTab, setActiveTab] = useState( + queryTab ?? + activeId ?? + defaultActiveId ?? + // if no defaultActiveId is set use the first panel + childrenArr?.[0]?.props?.id ) - if (stickyTabList && !stickyTabList.scrollMarginTop) { - // Magic number is the height of tab list + paragraph margin, worth getting - // rid of this? - stickyTabList.scrollMarginTop = 'calc(var(--header-height) + 43px + 20px)' - } + useEffect(() => { + // If we have a queryTab, Tabs is controller by URL params + if (queryTab && queryTab !== activeTab) { + setActiveTab(queryTab) + return + } + if (activeId && activeId !== activeTab) { + setActiveTab(activeId) + } + }, [activeId, activeTab, queryTab]) + const onTabClick = useCallback( + (id: string) => { + if (id !== activeTab) { + setActiveTab(id) + onTabSelectedForSticky() + onTabSelectedForQuery(id) + rerenderToc() + onChange?.(id) + onClick?.(id) + } + }, + [ + activeTab, + onTabSelectedForSticky, + onTabSelectedForQuery, + rerenderToc, + onChange, + onClick, + setActiveTab, + ] + ) return ( - + + + {addOnBefore} + {childrenArr.map((tab) => { + const isActive = activeTab === tab.props.id + + return ( + ) => { + if (e.key === 'Enter') { + e.preventDefault() + onTabClick(tab.props.id) + } + }} + onClick={() => onTabClick(tab.props.id)} + key={`${tab.props.id}-tab-button`} + value={tab.props.id} + className={tabsTriggerListVariants({ type, isActive, size, block })} + > + {tab.props.icon} + {tab.props.label} + {tab.props.iconRight} + + ) + })} + {addOnAfter} + + {childrenArr} + ) } -export { TabPanel, Tabs } +interface TabPanelProps { + children: ReactNode + id: string + label?: string + icon?: React.ReactNode + iconRight?: React.ReactNode + className?: string +} + +export const TabPanel = ({ children, id, className }: TabPanelProps) => { + return ( + + {children} + + ) +} diff --git a/apps/docs/features/ui/guide/GuideHeader.tsx b/apps/docs/features/ui/guide/GuideHeader.tsx index 034adb0eed0..be7e79b1434 100644 --- a/apps/docs/features/ui/guide/GuideHeader.tsx +++ b/apps/docs/features/ui/guide/GuideHeader.tsx @@ -1,6 +1,7 @@ 'use client' import ReactMarkdown from 'react-markdown' + import { useGuide } from './Guide' interface GuideHeaderProps { @@ -16,9 +17,9 @@ export function GuideHeader({ className }: GuideHeaderProps) { {meta?.title || 'Supabase Docs'} {meta?.subtitle && ( -

+
{meta.subtitle} -

+ )}
diff --git a/apps/docs/features/ui/useStickyTabs.tsx b/apps/docs/features/ui/useStickyTabs.tsx new file mode 100644 index 00000000000..d657271a9aa --- /dev/null +++ b/apps/docs/features/ui/useStickyTabs.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useCallback, useMemo, type FC } from 'react' + +import { useSticky } from './useStickyTabs.utils' + +export type UseStickyTabsOptions = { scrollMarginTop?: string; style?: CSSStyleDeclaration } + +export const useStickyTabs = (options?: UseStickyTabsOptions) => { + const enabled = !!options + const { scrollMarginTop, style } = options || {} + const { inView, observedRef, stickyRef } = useSticky({ + enabled, + style, + }) + + const onTabSelected = useCallback(() => { + if (enabled && inView && stickyRef.current) { + let elem = stickyRef.current as Element | null + while (elem && !elem.matches('[role="tabpanel"][data-state="active"]')) { + elem = elem.nextElementSibling + } + if (!elem) return + + const top = elem.getBoundingClientRect().top + ;(elem as HTMLElement).style.scrollMarginTop = scrollMarginTop || '0px' + if (top < 0) { + elem.scrollIntoView({ + behavior: window.matchMedia('(prefers-reduced-motion: no-preference)').matches + ? 'smooth' + : 'instant', + }) + } + } + }, [enabled, inView, scrollMarginTop, stickyRef]) + + return useMemo( + () => ({ + observedRef, + stickyRef, + onTabSelected, + }), + [observedRef, stickyRef, onTabSelected] + ) +} diff --git a/packages/ui-patterns/src/ComplexTabs/withSticky.utils.ts b/apps/docs/features/ui/useStickyTabs.utils.ts similarity index 100% rename from packages/ui-patterns/src/ComplexTabs/withSticky.utils.ts rename to apps/docs/features/ui/useStickyTabs.utils.ts diff --git a/apps/docs/features/ui/useTabsWithQueryParams.tsx b/apps/docs/features/ui/useTabsWithQueryParams.tsx new file mode 100644 index 00000000000..8d45d0dff17 --- /dev/null +++ b/apps/docs/features/ui/useTabsWithQueryParams.tsx @@ -0,0 +1,103 @@ +'use client' + +import { useSearchParamsShallow } from 'common' +import { xor } from 'lodash-es' +import { useCallback, useEffect, useMemo, useRef } from 'react' + +const LOCAL_STORAGE_KEY = 'supabase.ui-patterns.ComplexTabs.withQueryParams.v0' + +export interface UseTabsWithQueryParamsOptions { + tabIds: string[] + queryGroup?: string +} + +/** + * Wraps the basic `Tabs` component from the `ui` library so it stores + * selection state in query params. + */ +export const useTabsWithQueryParams = ({ tabIds, queryGroup }: UseTabsWithQueryParamsOptions) => { + // Store in ref to avoid stale data in later timeout + const tabIdsRef = useRef(tabIds) + tabIdsRef.current = tabIds + + // Store in ref to avoid stale data in later timeout + const queryGroupRef = useRef(queryGroup) + queryGroupRef.current = queryGroup + + const searchParams = useSearchParamsShallow() + const queryTabMaybe = queryGroupRef.current && searchParams.get(queryGroupRef.current) + const queryTab = + queryTabMaybe && tabIdsRef.current.includes(queryTabMaybe) ? queryTabMaybe : undefined + + const onTabSelected = useCallback( + (id: string) => { + if (queryGroupRef.current) { + if (!searchParams.getAll('queryGroups').includes(queryGroupRef.current)) { + searchParams.append('queryGroups', queryGroupRef.current) + } + searchParams.set(queryGroupRef.current, id) + } + }, + [searchParams] + ) + + const checkedLocalStorage = useRef(false) + useEffect(() => { + let timeout: number | undefined + if (!checkedLocalStorage.current) { + // Timeout to avoid something (I think the router) overwriting it + timeout = window.setTimeout(() => { + if ( + queryGroupRef.current && + !new URLSearchParams(window.location.search).has(queryGroupRef.current) + ) { + try { + const storedValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '') + if (storedValues === null || typeof storedValues !== 'object') return + + let storedValue: any = null + let maxDiff = tabIdsRef.current.length + Object.entries(storedValues).forEach(([key, value]) => { + const arr = key.split(',') + const diff = xor(arr, tabIdsRef.current) + if (diff.length < maxDiff) { + maxDiff = diff.length + storedValue = value + } + }) + + if (storedValue && tabIdsRef.current.includes(storedValue)) { + onTabSelected(storedValue) + } + } catch { + // ignore + } + } + }, 300) + + checkedLocalStorage.current = true + } + + if (queryGroupRef.current && queryTab) { + let updatedValues: Record = {} + try { + const oldValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '') + if (oldValues && typeof oldValues === 'object') { + updatedValues = oldValues + } + } catch { + // ignore + } + + updatedValues[tabIdsRef.current.sort().join(',')] = queryTab + + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedValues)) + } catch { + // ignore + } + } + }, [queryTab, onTabSelected]) + + return useMemo(() => ({ queryTab, onTabSelected }), [queryTab, onTabSelected]) +} diff --git a/apps/docs/fonts/index.ts b/apps/docs/fonts/index.ts new file mode 100644 index 00000000000..77c27d95a13 --- /dev/null +++ b/apps/docs/fonts/index.ts @@ -0,0 +1,15 @@ +import { Inter, Manrope } from 'next/font/google' + +export const manrope = Manrope({ + variable: '--font-manrope', + display: 'swap', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], + subsets: ['latin'], +}) + +export const inter = Inter({ + variable: '--font-inter', + display: 'swap', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], + subsets: ['latin'], +}) diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts index 39abdcba099..41944582ca4 100644 --- a/apps/docs/internals/generate-guides-markdown.ts +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises' import path from 'node:path' -import { globby } from 'globby' import matter from 'gray-matter' import type { Content, Parent, Root } from 'mdast' import { fromMarkdown } from 'mdast-util-from-markdown' @@ -10,6 +9,8 @@ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx' import { toMarkdown } from 'mdast-util-to-markdown' import { gfm } from 'micromark-extension-gfm' import { mdxjs } from 'micromark-extension-mdxjs' +import { parse as parseToml } from 'smol-toml' +import { mcpConfigPanelMarkdown as McpConfigPanel } from 'ui-patterns/McpUrlBuilder/McpConfigPanel.md' import { addBaseUrlPrefix } from './internal-links' import { Admonition } from './markdown-schema/Admonition' @@ -17,6 +18,7 @@ import { AuthProviders } from './markdown-schema/AuthProviders' import { ComputeDiskLimitsTable } from './markdown-schema/ComputeDiskLimitsTable' import { ErrorCodes } from './markdown-schema/ErrorCodes' import { Link } from './markdown-schema/Link' +import { ContentListings } from './markdown-schema/ContentListings' import { MetricsStackCards } from './markdown-schema/MetricsStackCards' import { NavData } from './markdown-schema/NavData' import { Panel } from './markdown-schema/Panel' @@ -26,8 +28,18 @@ import { RegionsList, SmartRegionsList } from './markdown-schema/RegionsList' import { SharedData } from './markdown-schema/SharedData' import { StepHike } from './markdown-schema/StepHike' import { TabPanel } from './markdown-schema/TabPanel' +import { + collectMarkdownSources, + type FrontmatterFormat, + type MarkdownSource, +} from './markdown-sources' const PARTIALS_DIR = path.join(process.cwd(), 'content', '_partials') +// The set of guide slugs that have a generated markdown (.md) variant. middleware.ts +// imports this to decide whether a bot request for /guides/ can be served +// markdown. It lives under the gitignored public/markdown/ output dir and is +// regenerated by build:guides-markdown, which turbo runs ahead of build/typecheck/lint. +const MANIFEST_PATH = path.join(process.cwd(), 'public', 'markdown', 'manifest.json') type JsxNode = MdxJsxFlowElement | MdxJsxTextElement type Props = Record @@ -142,6 +154,7 @@ const SCHEMA: ComponentSchema = { ComputeDiskLimitsTable, ErrorCodes, Link, + McpConfigPanel, Price, GlassPanel: Panel, IconPanel: Panel, @@ -151,14 +164,19 @@ const SCHEMA: ComponentSchema = { ...StepHike, TabPanel, MetricsStackCards, + ContentListings, NavData, SharedData, } -async function generateOne(filePath: string): Promise { - const raw = await fs.readFile(filePath, 'utf8') - const { content, data } = matter(raw) +function parseFrontmatter(raw: string, frontmatter: FrontmatterFormat) { + if (frontmatter === 'toml') { + return matter(raw, { language: 'toml', engines: { toml: parseToml } }) + } + return matter(raw) +} +async function transformBody(content: string, data: Record): Promise { const tree = parseMdx(content) await inlinePartials(tree) addBaseUrlPrefix(tree) @@ -166,43 +184,42 @@ async function generateOne(filePath: string): Promise { const body = serializeMdx(tree) const headerParts: string[] = [] - if (data.title) headerParts.push(`# ${data.title}`) + if (data.title) headerParts.push(`# ${String(data.title)}`) if (data.subtitle) headerParts.push(String(data.subtitle)) - // Add description only when differs from subtitle. if (data.description && String(data.description) !== String(data.subtitle)) headerParts.push(String(data.description)) const header = headerParts.join('\n\n') - return header ? `${header}\n\n${body}` : body + let output = header ? `${header}\n\n${body}` : body + + return output +} + +async function generateOne(sourceFile: string, frontmatter: FrontmatterFormat): Promise { + const raw = await fs.readFile(sourceFile, 'utf8') + const { content, data } = parseFrontmatter(raw, frontmatter) + return transformBody(content, data) +} + +function renderManifest(sources: MarkdownSource[]): string { + const slugs = [...new Set(sources.map((source) => source.slug))].sort() + return `${JSON.stringify(slugs, null, 2)}\n` } async function generate() { - const files = await globby(['content/guides/**/!(_)*.mdx']) - let warnings = 0 + const sources = await collectMarkdownSources() await Promise.all( - files.map(async (filePath) => { - // content/guides/ai/vector-columns.mdx → public/markdown/guides/ai/vector-columns.md - // Placing under public/markdown/ ensures the file is served at /docs/guides/... - // matching the URL of the rendered page. - const outPath = filePath - .replace(/^content\/guides\//, 'public/markdown/guides/') - .replace(/\.mdx$/, '.md') - + sources.map(async ({ sourceFile, outPath, frontmatter }) => { let output: string try { - output = await generateOne(filePath) + output = await generateOne(sourceFile, frontmatter) } catch (err) { - warnings++ - console.warn( - `[warn] Failed to process ${filePath}: ${err instanceof Error ? err.message : err}` + throw new Error( + `Failed to process ${sourceFile}: ${err instanceof Error ? err.message : err}`, + { cause: err } ) - try { - output = await fs.readFile(filePath, 'utf8') - } catch { - output = `` - } } await fs.mkdir(path.dirname(outPath), { recursive: true }) @@ -210,8 +227,15 @@ async function generate() { }) ) - const summary = warnings ? ` (${warnings} with warnings)` : '' - console.log(`Generated ${files.length} markdown files under public/markdown/guides/${summary}`) + await fs.mkdir(path.dirname(MANIFEST_PATH), { recursive: true }) + await fs.writeFile(MANIFEST_PATH, renderManifest(sources)) + + console.log( + `Generated ${sources.length} markdown files under public/markdown/guides/ and updated public/markdown/manifest.json` + ) } -generate() +generate().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/docs/internals/generate-reference-markdown.ts b/apps/docs/internals/generate-reference-markdown.ts index 7c751d07cf3..21e5cbfe593 100644 --- a/apps/docs/internals/generate-reference-markdown.ts +++ b/apps/docs/internals/generate-reference-markdown.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { isFeatureEnabled, type Feature } from 'common/enabled-features' import matter from 'gray-matter' import yaml from 'js-yaml' import { fromMarkdown } from 'mdast-util-from-markdown' @@ -41,6 +42,11 @@ type RefBase = { outFile: string /** Folder under docs/ref/ that holds .mdx sections for `type: "markdown"` entries. */ mdxDir: string + /** + * Enabled-features flag gating this reference. When set and the feature is + * disabled in enabled-features.json, the file is not generated. + */ + feature?: Feature } type SdkLegacyRef = RefBase & { @@ -93,12 +99,12 @@ const REFERENCES: Ref[] = [ contentDir: path.join(process.cwd(), 'content/reference/javascript/v2'), }, { - kind: 'sdk-legacy', + kind: 'sdk-new', title: 'Dart Client Library Reference', outFile: 'dart.md', mdxDir: path.join(MDX_ROOT, 'dart'), - sectionsPath: path.join(GENERATED, 'dart.v2.sections.json'), - functionsPath: path.join(GENERATED, 'dart.v2.functions.json'), + contentDir: path.join(process.cwd(), 'content/reference/dart/v2'), + feature: 'sdk:dart', }, { kind: 'sdk-legacy', @@ -107,6 +113,7 @@ const REFERENCES: Ref[] = [ mdxDir: path.join(MDX_ROOT, 'kotlin'), sectionsPath: path.join(GENERATED, 'kotlin.v1.sections.json'), functionsPath: path.join(GENERATED, 'kotlin.v1.functions.json'), + feature: 'sdk:kotlin', }, { kind: 'sdk-legacy', @@ -115,6 +122,7 @@ const REFERENCES: Ref[] = [ mdxDir: path.join(MDX_ROOT, 'python'), sectionsPath: path.join(GENERATED, 'python.v2.sections.json'), functionsPath: path.join(GENERATED, 'python.v2.functions.json'), + feature: 'sdk:python', }, { kind: 'sdk-legacy', @@ -123,6 +131,7 @@ const REFERENCES: Ref[] = [ mdxDir: path.join(MDX_ROOT, 'swift'), sectionsPath: path.join(GENERATED, 'swift.v2.sections.json'), functionsPath: path.join(GENERATED, 'swift.v2.functions.json'), + feature: 'sdk:swift', }, { kind: 'sdk-legacy', @@ -131,6 +140,7 @@ const REFERENCES: Ref[] = [ mdxDir: path.join(MDX_ROOT, 'csharp'), sectionsPath: path.join(GENERATED, 'csharp.v0.sections.json'), functionsPath: path.join(GENERATED, 'csharp.v0.functions.json'), + feature: 'sdk:csharp', }, ] @@ -393,8 +403,10 @@ async function generate() { path.join(process.cwd(), 'content/reference/javascript/v2/typeSpec.json') ) + const references = REFERENCES.filter((ref) => !ref.feature || isFeatureEnabled(ref.feature)) + await Promise.all( - REFERENCES.map(async (ref) => { + references.map(async (ref) => { let output: string switch (ref.kind) { case 'sdk-legacy': @@ -424,7 +436,7 @@ async function generate() { }) ) - console.log(`Generated ${REFERENCES.length} markdown files under public/markdown/reference/`) + console.log(`Generated ${references.length} markdown files under public/markdown/reference/`) } generate() diff --git a/apps/docs/internals/markdown-schema/ContentListings.ts b/apps/docs/internals/markdown-schema/ContentListings.ts new file mode 100644 index 00000000000..299b23c06ec --- /dev/null +++ b/apps/docs/internals/markdown-schema/ContentListings.ts @@ -0,0 +1,51 @@ +import { withDocsBasePath } from '~/internals/internal-links' +import type { ContentListingGroup } from '~/lib/content-listings.schema' +import { getContentListingById, isExternalContentListingHref } from '~/lib/content-listings.utils' + +import { getInternalLinkBaseUrl } from '../internal-links' + +const HEADING_MARKDOWN: Record<'h2' | 'h3' | 'h4', string> = { + h2: '##', + h3: '###', + h4: '####', +} + +export function serializeContentListingGroupToMarkdown( + group: ContentListingGroup, + linkBaseUrl: string +): string { + const lines: string[] = [] + if (group.heading) { + const level = group.headingLevel ?? 'h2' + lines.push(`${HEADING_MARKDOWN[level]} ${group.heading}`) + lines.push('') + } + + if (group.description) { + lines.push(group.description) + lines.push('') + } + + for (const item of group.items) { + const href = isExternalContentListingHref(item.href) + ? item.href + : `${linkBaseUrl}${withDocsBasePath(item.href)}` + lines.push(`- **[${item.title}](${href}):** ${item.description}`) + } + + return lines.join('\n') +} + +/** + * Markdown export handler for ``. Looks up the + * group by id in the same data registry the React component uses. + */ +export const ContentListings = ({ props }: { props: Record }): string => { + const id = typeof props.id === 'string' ? props.id : '' + if (!id) return '' + + const group = getContentListingById(id) + if (!group) return '' + + return serializeContentListingGroupToMarkdown(group, getInternalLinkBaseUrl()) +} diff --git a/apps/docs/internals/markdown-sources.test.ts b/apps/docs/internals/markdown-sources.test.ts new file mode 100644 index 00000000000..0db0b32dc11 --- /dev/null +++ b/apps/docs/internals/markdown-sources.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { guideSlug, troubleshootingSlug } from './markdown-sources' + +describe('guideSlug', () => { + it('strips the content/guides prefix and .mdx extension, keeping nested path', () => { + expect(guideSlug('content/guides/ai/vector-columns.mdx')).toBe('ai/vector-columns') + }) + + it('handles a top-level guide', () => { + expect(guideSlug('content/guides/getting-started.mdx')).toBe('getting-started') + }) +}) + +describe('troubleshootingSlug', () => { + it('flattens to a troubleshooting/ slug', () => { + expect(troubleshootingSlug('content/troubleshooting/all-about-egress-a_Sg_e.mdx')).toBe( + 'troubleshooting/all-about-egress-a_Sg_e' + ) + }) +}) diff --git a/apps/docs/internals/markdown-sources.ts b/apps/docs/internals/markdown-sources.ts new file mode 100644 index 00000000000..808d29d7210 --- /dev/null +++ b/apps/docs/internals/markdown-sources.ts @@ -0,0 +1,42 @@ +import path from 'node:path' +import { globby } from 'globby' + +export type FrontmatterFormat = 'yaml' | 'toml' + +export interface MarkdownSource { + sourceFile: string + slug: string + outPath: string + frontmatter: FrontmatterFormat +} + +const OUTPUT_ROOT = 'public/markdown/guides' +const GUIDES_GLOB = 'content/guides/**/!(_)*.mdx' +const TROUBLESHOOTING_GLOB = 'content/troubleshooting/!(_)*.mdx' + +export function guideSlug(sourceFile: string): string { + return sourceFile.replace(/^content\/guides\//, '').replace(/\.mdx$/, '') +} + +export function troubleshootingSlug(sourceFile: string): string { + return `troubleshooting/${path.basename(sourceFile, '.mdx')}` +} + +export async function collectMarkdownSources(): Promise { + const [guideFiles, troubleshootingFiles] = await Promise.all([ + globby([GUIDES_GLOB]), + globby([TROUBLESHOOTING_GLOB]), + ]) + + const guides: MarkdownSource[] = guideFiles.map((sourceFile) => { + const slug = guideSlug(sourceFile) + return { sourceFile, slug, outPath: `${OUTPUT_ROOT}/${slug}.md`, frontmatter: 'yaml' } + }) + + const troubleshooting: MarkdownSource[] = troubleshootingFiles.map((sourceFile) => { + const slug = troubleshootingSlug(sourceFile) + return { sourceFile, slug, outPath: `${OUTPUT_ROOT}/${slug}.md`, frontmatter: 'toml' } + }) + + return [...guides, ...troubleshooting] +} diff --git a/apps/docs/layouts/MainSkeleton.tsx b/apps/docs/layouts/MainSkeleton.tsx index 906fc2b879c..048bed82157 100644 --- a/apps/docs/layouts/MainSkeleton.tsx +++ b/apps/docs/layouts/MainSkeleton.tsx @@ -283,8 +283,9 @@ const Container = memo(function Container({
+export type ContentListingGroup = z.infer diff --git a/apps/docs/lib/content-listings.test.ts b/apps/docs/lib/content-listings.test.ts new file mode 100644 index 00000000000..61a067ad726 --- /dev/null +++ b/apps/docs/lib/content-listings.test.ts @@ -0,0 +1,166 @@ +import { storageGetStarted } from '~/data/content-listings/storage.data' +import { + ContentListings as ContentListingsMarkdownHandler, + serializeContentListingGroupToMarkdown, +} from '~/internals/markdown-schema/ContentListings' +import { isExternalContentListingHref } from '~/lib/content-listings.utils' +import { describe, expect, it } from 'vitest' + +describe('isExternalContentListingHref', () => { + it('treats protocol-relative URLs as external', () => { + expect(isExternalContentListingHref('//example.com/path')).toBe(true) + }) + + it('treats absolute http(s) URLs as external', () => { + expect(isExternalContentListingHref('https://github.com/supabase/storage-api')).toBe(true) + }) + + it('treats internal guide paths as not external', () => { + expect(isExternalContentListingHref('/guides/storage/quickstart')).toBe(false) + }) +}) + +describe('serializeContentListingGroupToMarkdown', () => { + it('renders grouped items with absolute URLs and descriptions', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + heading: 'Get started', + description: 'Read these first.', + items: [ + { + title: 'Connect to your database', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings and pooler modes.', + }, + ], + }, + 'https://supabase.com' + ) + + expect(markdown).toContain('## Get started') + expect(markdown).toContain('Read these first.') + expect(markdown).toContain( + '**[Connect to your database](https://supabase.com/docs/guides/database/connecting-to-postgres):** Connection strings and pooler modes.' + ) + }) + + it('preserves external hrefs in markdown export', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'resources', + items: [ + { + title: 'Storage API', + href: 'https://github.com/supabase/storage-api', + description: 'View the source code.', + }, + ], + }, + 'https://supabase.com' + ) + + expect(markdown).toContain( + '**[Storage API](https://github.com/supabase/storage-api):** View the source code.' + ) + }) + + it('respects heading-level in markdown export', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + heading: 'Get started', + headingLevel: 'h3', + items: [ + { + title: 'Connect', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings.', + }, + ], + }, + '' + ) + + expect(markdown).toContain('### Get started') + }) + + it('renders heading and description only once', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + heading: 'Get started', + description: 'Read these first.', + items: [ + { + title: 'Connect', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings.', + }, + ], + }, + '' + ) + + expect(markdown.match(/^## Get started$/gm)).toHaveLength(1) + expect(markdown.match(/^Read these first\.$/gm)).toHaveLength(1) + expect(markdown).toBe( + '## Get started\n\nRead these first.\n\n- **[Connect](/docs/guides/database/connecting-to-postgres):** Connection strings.' + ) + }) + + it('omits heading line when heading is not set', () => { + const markdown = serializeContentListingGroupToMarkdown( + { + id: 'get-started', + items: [ + { + title: 'Connect', + href: '/guides/database/connecting-to-postgres', + description: 'Connection strings.', + }, + ], + }, + '' + ) + + expect(markdown).not.toMatch(/^#+\s/m) + expect(markdown).toContain('**[Connect]') + }) +}) + +describe('ContentListings markdown handler', () => { + it('serializes the group looked up by id', () => { + const markdown = ContentListingsMarkdownHandler({ props: { id: 'storage-get-started' } }) + + expect(markdown).toContain('## Get started') + expect(markdown).toContain('Files buckets') + expect(markdown).toContain('/guides/storage/quickstart') + }) + + it('returns empty string when id is missing or unknown', () => { + expect(ContentListingsMarkdownHandler({ props: {} })).toBe('') + expect(ContentListingsMarkdownHandler({ props: { id: 'nonexistent-id' } })).toBe('') + }) + + it('matches direct serializeContentListingGroupToMarkdown for the same data', () => { + const handler = ContentListingsMarkdownHandler({ props: { id: 'storage-get-started' } }) + const direct = serializeContentListingGroupToMarkdown(storageGetStarted, '') + expect(handler).toBe(direct) + }) +}) + +describe('TelemetryEvent union', () => { + it('includes docs_content_listing_clicked', () => { + const event = { + action: 'docs_content_listing_clicked' as const, + properties: { + targetPath: '/guides/storage', + linkTitle: 'Storage', + }, + } + + const _typeCheck: import('common/telemetry-constants').TelemetryEvent = event + expect(_typeCheck.action).toBe('docs_content_listing_clicked') + }) +}) diff --git a/apps/docs/lib/content-listings.utils.ts b/apps/docs/lib/content-listings.utils.ts new file mode 100644 index 00000000000..10c73b4d4f1 --- /dev/null +++ b/apps/docs/lib/content-listings.utils.ts @@ -0,0 +1,16 @@ +import { CONTENT_LISTINGS } from '~/data/content-listings' + +import type { ContentListingGroup } from './content-listings.schema' + +/** Label for telemetry — prefers heading, falls back to id. */ +export function getContentListingGroupLabel(group: ContentListingGroup): string { + return group.heading ?? group.id +} + +export function isExternalContentListingHref(href: string): boolean { + return /^https?:\/\//i.test(href) || href.startsWith('//') +} + +export function getContentListingById(id: string): ContentListingGroup | undefined { + return CONTENT_LISTINGS[id] +} diff --git a/apps/docs/lib/content-listings.zod.mjs b/apps/docs/lib/content-listings.zod.mjs new file mode 100644 index 00000000000..4480879288e --- /dev/null +++ b/apps/docs/lib/content-listings.zod.mjs @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export const contentListingItemSchema = z.object({ + title: z.string().min(1), + href: z.string().min(1), + description: z.string().min(1), + icon: z.string().min(1).optional(), +}) + +export const contentListingGroupTypeSchema = z.enum(['list', 'grid']) + +export const contentListingGridColumnsSchema = z.union([z.literal(2), z.literal(3), z.literal(4)]) + +export const contentListingHeadingLevelSchema = z.enum(['h2', 'h3', 'h4']) + +export const contentListingGroupSchema = z.object({ + id: z.string().min(1), + heading: z.string().min(1).optional(), + headingLevel: contentListingHeadingLevelSchema.optional(), + description: z.string().optional(), + type: contentListingGroupTypeSchema.optional(), + columns: contentListingGridColumnsSchema.optional(), + items: z.array(contentListingItemSchema).min(1), +}) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 42817c237c7..4bbeeed8a65 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,11 +1,13 @@ import { clientSdkIds } from '~/content/navigation.references' import { BASE_PATH } from '~/lib/constants' +import MARKDOWN_SLUGS from '~/public/markdown/manifest.json' import { negotiateMarkdown } from 'common/markdown-negotiation' import { isbot } from 'isbot' import { NextResponse, type NextRequest } from 'next/server' const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference` const GUIDES_PATH = `${BASE_PATH ?? ''}/guides` +const GUIDES_MARKDOWN_SLUGS = new Set(MARKDOWN_SLUGS) export function middleware(request: NextRequest) { const url = new URL(request.url) @@ -19,7 +21,7 @@ export function middleware(request: NextRequest) { acceptHeader: request.headers.get('accept') ?? '', userAgent: request.headers.get('user-agent') ?? '', }, - { hasMarkdownVariant: true, isMarkdownSuffix: isMdSuffix } + { hasMarkdownVariant: GUIDES_MARKDOWN_SLUGS.has(slug), isMarkdownSuffix: isMdSuffix } ) if (decision === 'not-acceptable') { diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 8fa45e13c6d..4da99c4f570 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -156,20 +156,35 @@ const nextConfig = { permanent: false, }, - // Redirect old external replication slugs in dev/preview envs + // Redirect old managed pipeline slugs in dev/preview envs + { + source: '/guides/database/replication/external-replication-setup', + destination: '/guides/database/replication/pipelines', + permanent: true, + }, + { + source: '/guides/database/replication/external-replication-monitoring', + destination: '/guides/database/replication/pipelines-monitoring', + permanent: true, + }, + { + source: '/guides/database/replication/external-replication-faq', + destination: '/guides/database/replication/pipelines-faq', + permanent: true, + }, { source: '/guides/database/replication/replication-setup', - destination: '/guides/database/replication/external-replication-setup', + destination: '/guides/database/replication/pipelines', permanent: true, }, { source: '/guides/database/replication/replication-monitoring', - destination: '/guides/database/replication/external-replication-monitoring', + destination: '/guides/database/replication/pipelines-monitoring', permanent: true, }, { source: '/guides/database/replication/replication-faq', - destination: '/guides/database/replication/external-replication-faq', + destination: '/guides/database/replication/pipelines-faq', permanent: true, }, ] diff --git a/apps/docs/package.json b/apps/docs/package.json index bf086e3a985..c82cd4ca9f1 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,7 +16,9 @@ "codegen:examples": "shx cp -r ../../examples ./examples", "codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts", "codegen:references:new:ensure": "test -f spec/reference/javascript/v2/supabase.json || (cd spec && make download.tsdoc.v2)", - "codegen:references:new": "pnpm run codegen:references:new:ensure && tsx scripts/build-reference-content.ts", + "codegen:references:dart": "tsx scripts/generate-dart-reference.ts", + "precodegen:references:new": "pnpm run codegen:references:new:ensure && pnpm run codegen:references:dart", + "codegen:references:new": "tsx scripts/build-reference-content.ts", "codegen:references:legacy": "tsx features/docs/Reference.generated.script.ts", "codegen:references": "pnpm codegen:references:legacy && pnpm codegen:references:new", "codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --cache --write \"content/**/*.mdx\"", @@ -33,12 +35,15 @@ "lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml", "postbuild": "pnpm run build:sitemap && ./../../scripts/upload-static-assets.sh", "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm run build:markdown && pnpm run build:gz-archive", - "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples", + "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm run build:markdown", "preembeddings": "pnpm run codegen:references", "preinstall": "npx only-allow pnpm", "presync": "pnpm run codegen:graphql", "pretest": "pnpm run codegen:examples", - "pretypecheck": "pnpm run codegen:graphql && next typegen", + "pretest:local": "pnpm run build:guides-markdown", + "pretest:local:unwatch": "pnpm run build:guides-markdown", + "pretest:smoke": "pnpm run build:guides-markdown", + "pretypecheck": "pnpm run codegen:graphql && pnpm run build:markdown && next typegen", "start": "next start", "sync": "tsx --conditions=react-server ./resources/rootSync.ts", "test": "pnpm supabase start && pnpm run test:local && pnpm supabase stop", @@ -62,11 +67,12 @@ "@octokit/plugin-retry": "^8.1.0", "@sentry/nextjs": "catalog:", "@supabase/supabase-js": "catalog:", - "@tanstack/react-query": "^5.13.4", + "@tanstack/react-query": "~5.83.0", "acorn": "^8.11.3", "acorn-typescript": "^1.4.13", "ai-commands": "workspace:*", "beautiful-mermaid": "^1.1.3", + "class-variance-authority": "^0.7.1", "common": "workspace:*", "common-tags": "^1.8.2", "config": "workspace:*", @@ -137,6 +143,7 @@ "@graphql-codegen/cli": "5.0.5", "@graphql-codegen/typescript": "4.1.6", "@graphql-codegen/typescript-resolvers": "4.5.0", + "@redocly/cli": "^2.26.0", "@supabase/supa-mdx-lint": "0.3.2", "@testing-library/react": "^16.0.0", "@types/common-tags": "^1.8.4", @@ -151,7 +158,6 @@ "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-dom": "catalog:", "@types/unist": "^2.0.6", - "@redocly/cli": "^2.25.3", "amaro": "^1.1.5", "api-types": "workspace:*", "cheerio": "^1.0.0-rc.12", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 8bc82093f2c..511922f0679 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -8,6 +8,7 @@ Adam Mohammed Adam Mokan AJ Matias Akash Manimaran +Akilesh Sethu Alaister Young Aleksi Immonen Alex Hall @@ -41,6 +42,7 @@ Brent Newson Brian Cullen Brent Schroeter Bruno Kilian +Burhan A Carel de Waal Cameron Blackwood Cameron Michie @@ -158,6 +160,7 @@ Julian Domke Julien Goux Kai M Kalleby Santos +Kanad Gupta Kanishk Dudeja Kamil Ogórek Kang Ming Tay @@ -183,6 +186,7 @@ Liam Cloud Hogan Lindsay Moss Lukas Bernert Lukas Klingsbo +Łukasz Korbasiewicz Łukasz Niemier Maksym Ionutsa Manuel Mendez @@ -190,6 +194,7 @@ Manan Gupta Marcus Weiner Marija Milicevic Mark Burggraf +Márton Boros Matthew Hambright Matt Hudson Matt Johnston @@ -217,6 +222,7 @@ Oli R Pamela Chia Patricia Szasz Patrick Crane +Paul Bauer Paul Caselton Paul Cioanca Paul Copplestone diff --git a/apps/docs/public/img/guides/auth-mfa/auth-mfa-flow.svg b/apps/docs/public/img/guides/auth-mfa/auth-mfa-flow.svg deleted file mode 100644 index d7dd78a4bf0..00000000000 --- a/apps/docs/public/img/guides/auth-mfa/auth-mfa-flow.svg +++ /dev/null @@ -1 +0,0 @@ -
Yes
No
1 or more factors
0 factors
Setup flow
Session is AAL1
Enroll API
Show QR code
User: Scan QR code in authenticator
User: Enter code
Challenge + Verify API
Is code correct?
Upgrade to AAL2
Done
Login flow
User: Sign-in
Upgrade to AAL1
List Factors API
User: Open authenticator
Setup flow
\ No newline at end of file diff --git a/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone-flow.svg b/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone-flow.svg deleted file mode 100644 index 976dd3525c4..00000000000 --- a/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone-flow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
Yes
No
1 or more factors
0 factors
Setup flow
Session is AAL1
Enroll API
Challenge API
Code sent to User
User: Enter code
Verify API
Is code correct?
Upgrade to AAL2
Done
Login flow
User: Sign-in
Upgrade to AAL1
List Factors API
User: Select phone factor
Setup flow
\ No newline at end of file diff --git a/apps/docs/public/img/guides/auth-signing-keys/states.svg b/apps/docs/public/img/guides/auth-signing-keys/states.svg deleted file mode 100644 index fc49db026d7..00000000000 --- a/apps/docs/public/img/guides/auth-signing-keys/states.svg +++ /dev/null @@ -1,2 +0,0 @@ - -

A new key is created and advertized

Once all components have picked up the new key, new JWTs can be issued with it

Rotation, JWT remain accepted

Once all JWTs created with the previous key expire (or sooner)

Delete permanently after 7 days

standby

in_use

previously_used

revoked

diff --git a/apps/docs/public/img/guides/cli/snaplet-example-schema.png b/apps/docs/public/img/guides/cli/snaplet-example-schema.png deleted file mode 100644 index dab45e505a4..00000000000 Binary files a/apps/docs/public/img/guides/cli/snaplet-example-schema.png and /dev/null differ diff --git a/apps/docs/public/img/guides/cli/workflow.png b/apps/docs/public/img/guides/cli/workflow.png deleted file mode 100644 index f23f1c21905..00000000000 Binary files a/apps/docs/public/img/guides/cli/workflow.png and /dev/null differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/connection-decision-tree-light.svg b/apps/docs/public/img/guides/database/connecting-to-postgres/connection-decision-tree-light.svg deleted file mode 100644 index 51bfe3a7a42..00000000000 --- a/apps/docs/public/img/guides/database/connecting-to-postgres/connection-decision-tree-light.svg +++ /dev/null @@ -1 +0,0 @@ -

Where are you connecting from?

Persistent Backend

Serverless / Edge

IPv6 Supported?
IPv4 Add-on?

IPv4 Needed?

Use Direct Connection

Use Supavisor Session Mode

IPv6 Supported?
IPv4 Add-on?

IPv4 Needed?

Use Dedicated Pooler PgBouncer_Pro

Use Supavisor Transaction Mode

\ No newline at end of file diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/connection-decision-tree.svg b/apps/docs/public/img/guides/database/connecting-to-postgres/connection-decision-tree.svg deleted file mode 100644 index 576e76ea87d..00000000000 --- a/apps/docs/public/img/guides/database/connecting-to-postgres/connection-decision-tree.svg +++ /dev/null @@ -1,102 +0,0 @@ -

Where are you connecting from?

Persistent Backend

Serverless / Edge

IPv6 Supported?
IPv4 Add-on?

IPv4 Needed?

Use Direct Connection

Use Supavisor Session Mode

IPv6 Supported?
IPv4 Add-on?

IPv4 Needed?

Use Dedicated Pooler PgBouncer_Pro

Use Supavisor Transaction Mode

\ No newline at end of file diff --git a/apps/docs/public/img/guides/integrations/onesignal/diagram.png b/apps/docs/public/img/guides/integrations/onesignal/diagram.png deleted file mode 100644 index 77b83ca5315..00000000000 Binary files a/apps/docs/public/img/guides/integrations/onesignal/diagram.png and /dev/null differ diff --git a/apps/docs/public/img/guides/integrations/zuplo/arch.png b/apps/docs/public/img/guides/integrations/zuplo/arch.png deleted file mode 100644 index af425e632a2..00000000000 Binary files a/apps/docs/public/img/guides/integrations/zuplo/arch.png and /dev/null differ diff --git a/apps/docs/public/img/guides/platform/read-replicas/read-replicas-flow.svg b/apps/docs/public/img/guides/platform/read-replicas/read-replicas-flow.svg deleted file mode 100644 index a49c06eb103..00000000000 --- a/apps/docs/public/img/guides/platform/read-replicas/read-replicas-flow.svg +++ /dev/null @@ -1 +0,0 @@ -

No

Yes

No

Yes

No

Yes

Yes

No

Yes

No

Database slowing down

CPU above 70% sustained?

Monitor, do not scale yet

Queries optimized? Indexes in place?

Run EXPLAIN ANALYZE
Add missing indexes
Optimize first

Workload 80%+ reads?

Upgrade compute
Replicas will not help writes

Already at 16XL?

Read Replicas
Only horizontal option left

Need workload isolation
or geo-distribution?

Read Replicas

Either works
Compute is simpler
Replicas scale further

\ No newline at end of file diff --git a/apps/docs/public/img/guides/realtime/realtime-arch.png b/apps/docs/public/img/guides/realtime/realtime-arch.png deleted file mode 100644 index fca216142b1..00000000000 Binary files a/apps/docs/public/img/guides/realtime/realtime-arch.png and /dev/null differ diff --git a/apps/docs/public/img/storage/schema-design.png b/apps/docs/public/img/storage/schema-design.png deleted file mode 100644 index 79401732ce2..00000000000 Binary files a/apps/docs/public/img/storage/schema-design.png and /dev/null differ diff --git a/apps/docs/scripts/__snapshots__/build-reference-content.dart.v2.json b/apps/docs/scripts/__snapshots__/build-reference-content.dart.v2.json new file mode 100644 index 00000000000..3e4ee363394 --- /dev/null +++ b/apps/docs/scripts/__snapshots__/build-reference-content.dart.v2.json @@ -0,0 +1,5313 @@ +{ + "bySlug": { + "introduction": { + "id": "introduction", + "title": "Introduction", + "slug": "introduction", + "type": "markdown" + }, + "installing": { + "id": "installing", + "title": "Installing", + "slug": "installing", + "type": "markdown" + }, + "initializing": { + "id": "initializing", + "title": "Initializing", + "slug": "initializing", + "type": "function" + }, + "upgrade-guide": { + "id": "upgrade-guide", + "title": "Upgrade guide", + "slug": "upgrade-guide", + "type": "markdown" + }, + "database": { + "type": "category", + "title": "Database" + }, + "delete": { + "id": "delete", + "title": "delete", + "slug": "delete", + "product": "database", + "type": "function" + }, + "insert": { + "id": "insert", + "title": "insert", + "slug": "insert", + "product": "database", + "type": "function" + }, + "rpc": { + "id": "rpc", + "title": "rpc", + "slug": "rpc", + "product": "database", + "type": "function" + }, + "select": { + "id": "select", + "title": "select", + "slug": "select", + "product": "database", + "type": "function" + }, + "update": { + "id": "update", + "title": "update", + "slug": "update", + "product": "database", + "type": "function" + }, + "upsert": { + "id": "upsert", + "title": "upsert", + "slug": "upsert", + "product": "database", + "type": "function" + }, + "using-filters": { + "id": "using-filters", + "title": "Using filters", + "slug": "using-filters", + "type": "function" + }, + "using-filters-containedby": { + "id": "using-filters-containedby", + "title": "containedBy", + "slug": "using-filters-containedby", + "product": "database", + "type": "function" + }, + "using-filters-contains": { + "id": "using-filters-contains", + "title": "contains", + "slug": "using-filters-contains", + "product": "database", + "type": "function" + }, + "using-filters-eq": { + "id": "using-filters-eq", + "title": "eq", + "slug": "using-filters-eq", + "product": "database", + "type": "function" + }, + "using-filters-filter": { + "id": "using-filters-filter", + "title": "filter", + "slug": "using-filters-filter", + "product": "database", + "type": "function" + }, + "using-filters-gt": { + "id": "using-filters-gt", + "title": "gt", + "slug": "using-filters-gt", + "product": "database", + "type": "function" + }, + "using-filters-gte": { + "id": "using-filters-gte", + "title": "gte", + "slug": "using-filters-gte", + "product": "database", + "type": "function" + }, + "using-filters-ilike": { + "id": "using-filters-ilike", + "title": "ilike", + "slug": "using-filters-ilike", + "product": "database", + "type": "function" + }, + "using-filters-infilter": { + "id": "using-filters-infilter", + "title": "inFilter", + "slug": "using-filters-infilter", + "product": "database", + "type": "function" + }, + "using-filters-isfilter": { + "id": "using-filters-isfilter", + "title": "isFilter", + "slug": "using-filters-isfilter", + "product": "database", + "type": "function" + }, + "using-filters-like": { + "id": "using-filters-like", + "title": "like", + "slug": "using-filters-like", + "product": "database", + "type": "function" + }, + "using-filters-lt": { + "id": "using-filters-lt", + "title": "lt", + "slug": "using-filters-lt", + "product": "database", + "type": "function" + }, + "using-filters-lte": { + "id": "using-filters-lte", + "title": "lte", + "slug": "using-filters-lte", + "product": "database", + "type": "function" + }, + "using-filters-match": { + "id": "using-filters-match", + "title": "match", + "slug": "using-filters-match", + "product": "database", + "type": "function" + }, + "using-filters-neq": { + "id": "using-filters-neq", + "title": "neq", + "slug": "using-filters-neq", + "product": "database", + "type": "function" + }, + "using-filters-not": { + "id": "using-filters-not", + "title": "not", + "slug": "using-filters-not", + "product": "database", + "type": "function" + }, + "using-filters-or": { + "id": "using-filters-or", + "title": "or", + "slug": "using-filters-or", + "product": "database", + "type": "function" + }, + "using-filters-overlaps": { + "id": "using-filters-overlaps", + "title": "overlaps", + "slug": "using-filters-overlaps", + "product": "database", + "type": "function" + }, + "using-filters-rangeadjacent": { + "id": "using-filters-rangeadjacent", + "title": "rangeAdjacent", + "slug": "using-filters-rangeadjacent", + "product": "database", + "type": "function" + }, + "using-filters-rangegt": { + "id": "using-filters-rangegt", + "title": "rangeGt", + "slug": "using-filters-rangegt", + "product": "database", + "type": "function" + }, + "using-filters-rangegte": { + "id": "using-filters-rangegte", + "title": "rangeGte", + "slug": "using-filters-rangegte", + "product": "database", + "type": "function" + }, + "using-filters-rangelt": { + "id": "using-filters-rangelt", + "title": "rangeLt", + "slug": "using-filters-rangelt", + "product": "database", + "type": "function" + }, + "using-filters-rangelte": { + "id": "using-filters-rangelte", + "title": "rangeLte", + "slug": "using-filters-rangelte", + "product": "database", + "type": "function" + }, + "using-filters-textsearch": { + "id": "using-filters-textsearch", + "title": "textSearch", + "slug": "using-filters-textsearch", + "product": "database", + "type": "function" + }, + "using-modifiers": { + "id": "using-modifiers", + "title": "Using modifiers", + "slug": "using-modifiers", + "type": "function" + }, + "using-modifiers-csv": { + "id": "using-modifiers-csv", + "title": "csv", + "slug": "using-modifiers-csv", + "product": "database", + "type": "function" + }, + "using-modifiers-explain": { + "id": "using-modifiers-explain", + "title": "explain", + "slug": "using-modifiers-explain", + "product": "database", + "type": "function" + }, + "using-modifiers-limit": { + "id": "using-modifiers-limit", + "title": "limit", + "slug": "using-modifiers-limit", + "product": "database", + "type": "function" + }, + "using-modifiers-maxaffected": { + "id": "using-modifiers-maxaffected", + "title": "maxAffected", + "slug": "using-modifiers-maxaffected", + "product": "database", + "type": "function" + }, + "using-modifiers-maybesingle": { + "id": "using-modifiers-maybesingle", + "title": "maybeSingle", + "slug": "using-modifiers-maybesingle", + "product": "database", + "type": "function" + }, + "using-modifiers-order": { + "id": "using-modifiers-order", + "title": "order", + "slug": "using-modifiers-order", + "product": "database", + "type": "function" + }, + "using-modifiers-range": { + "id": "using-modifiers-range", + "title": "range", + "slug": "using-modifiers-range", + "product": "database", + "type": "function" + }, + "using-modifiers-select": { + "id": "using-modifiers-select", + "title": "select", + "slug": "using-modifiers-select", + "product": "database", + "type": "function" + }, + "using-modifiers-single": { + "id": "using-modifiers-single", + "title": "single", + "slug": "using-modifiers-single", + "product": "database", + "type": "function" + }, + "auth": { + "type": "category", + "title": "Auth" + }, + "auth-currentsession": { + "id": "auth-currentsession", + "title": "currentSession", + "slug": "auth-currentsession", + "product": "auth", + "type": "function" + }, + "auth-currentuser": { + "id": "auth-currentuser", + "title": "currentUser", + "slug": "auth-currentuser", + "product": "auth", + "type": "function" + }, + "auth-getuseridentities": { + "id": "auth-getuseridentities", + "title": "getUserIdentities", + "slug": "auth-getuseridentities", + "product": "auth", + "type": "function" + }, + "auth-linkidentity": { + "id": "auth-linkidentity", + "title": "linkIdentity", + "slug": "auth-linkidentity", + "product": "auth", + "type": "function" + }, + "auth-linkidentitywithidtoken": { + "id": "auth-linkidentitywithidtoken", + "title": "linkIdentityWithIdToken", + "slug": "auth-linkidentitywithidtoken", + "product": "auth", + "type": "function" + }, + "auth-onauthstatechange": { + "id": "auth-onauthstatechange", + "title": "onAuthStateChange", + "slug": "auth-onauthstatechange", + "product": "auth", + "type": "function" + }, + "auth-reauthenticate": { + "id": "auth-reauthenticate", + "title": "reauthenticate", + "slug": "auth-reauthenticate", + "product": "auth", + "type": "function" + }, + "auth-refreshsession": { + "id": "auth-refreshsession", + "title": "refreshSession", + "slug": "auth-refreshsession", + "product": "auth", + "type": "function" + }, + "auth-registerpasskey": { + "id": "auth-registerpasskey", + "title": "registerPasskey", + "slug": "auth-registerpasskey", + "product": "auth", + "type": "function" + }, + "auth-resend": { + "id": "auth-resend", + "title": "resend", + "slug": "auth-resend", + "product": "auth", + "type": "function" + }, + "auth-resetpasswordforemail": { + "id": "auth-resetpasswordforemail", + "title": "resetPasswordForEmail", + "slug": "auth-resetpasswordforemail", + "product": "auth", + "type": "function" + }, + "auth-setsession": { + "id": "auth-setsession", + "title": "setSession", + "slug": "auth-setsession", + "product": "auth", + "type": "function" + }, + "auth-signinanonymously": { + "id": "auth-signinanonymously", + "title": "signInAnonymously", + "slug": "auth-signinanonymously", + "product": "auth", + "type": "function" + }, + "auth-signinwithidtoken": { + "id": "auth-signinwithidtoken", + "title": "signInWithIdToken", + "slug": "auth-signinwithidtoken", + "product": "auth", + "type": "function" + }, + "auth-signinwithoauth": { + "id": "auth-signinwithoauth", + "title": "signInWithOAuth", + "slug": "auth-signinwithoauth", + "product": "auth", + "type": "function" + }, + "auth-signinwithotp": { + "id": "auth-signinwithotp", + "title": "signInWithOtp", + "slug": "auth-signinwithotp", + "product": "auth", + "type": "function" + }, + "auth-signinwithpasskey": { + "id": "auth-signinwithpasskey", + "title": "signInWithPasskey", + "slug": "auth-signinwithpasskey", + "product": "auth", + "type": "function" + }, + "auth-signinwithpassword": { + "id": "auth-signinwithpassword", + "title": "signInWithPassword", + "slug": "auth-signinwithpassword", + "product": "auth", + "type": "function" + }, + "auth-signinwithsso": { + "id": "auth-signinwithsso", + "title": "signInWithSSO", + "slug": "auth-signinwithsso", + "product": "auth", + "type": "function" + }, + "auth-signout": { + "id": "auth-signout", + "title": "signOut", + "slug": "auth-signout", + "product": "auth", + "type": "function" + }, + "auth-signup": { + "id": "auth-signup", + "title": "signUp", + "slug": "auth-signup", + "product": "auth", + "type": "function" + }, + "auth-unlinkidentity": { + "id": "auth-unlinkidentity", + "title": "unlinkIdentity", + "slug": "auth-unlinkidentity", + "product": "auth", + "type": "function" + }, + "auth-updateuser": { + "id": "auth-updateuser", + "title": "updateUser", + "slug": "auth-updateuser", + "product": "auth", + "type": "function" + }, + "auth-verifyotp": { + "id": "auth-verifyotp", + "title": "verifyOtp", + "slug": "auth-verifyotp", + "product": "auth", + "type": "function" + }, + "auth-admin": { + "id": "auth-admin", + "title": "Auth Admin", + "slug": "auth-admin", + "type": "function" + }, + "auth-admin-createuser": { + "id": "auth-admin-createuser", + "title": "createUser", + "slug": "auth-admin-createuser", + "product": "auth", + "type": "function" + }, + "auth-admin-deleteuser": { + "id": "auth-admin-deleteuser", + "title": "deleteUser", + "slug": "auth-admin-deleteuser", + "product": "auth", + "type": "function" + }, + "auth-admin-generatelink": { + "id": "auth-admin-generatelink", + "title": "generateLink", + "slug": "auth-admin-generatelink", + "product": "auth", + "type": "function" + }, + "auth-admin-getuserbyid": { + "id": "auth-admin-getuserbyid", + "title": "getUserById", + "slug": "auth-admin-getuserbyid", + "product": "auth", + "type": "function" + }, + "auth-admin-inviteuserbyemail": { + "id": "auth-admin-inviteuserbyemail", + "title": "inviteUserByEmail", + "slug": "auth-admin-inviteuserbyemail", + "product": "auth", + "type": "function" + }, + "auth-admin-listusers": { + "id": "auth-admin-listusers", + "title": "listUsers", + "slug": "auth-admin-listusers", + "product": "auth", + "type": "function" + }, + "auth-admin-updateuserbyid": { + "id": "auth-admin-updateuserbyid", + "title": "updateUserById", + "slug": "auth-admin-updateuserbyid", + "product": "auth", + "type": "function" + }, + "auth-mfa": { + "id": "auth-mfa", + "title": "Auth MFA", + "slug": "auth-mfa", + "type": "function" + }, + "auth-mfa-challenge": { + "id": "auth-mfa-challenge", + "title": "challenge", + "slug": "auth-mfa-challenge", + "product": "auth", + "type": "function" + }, + "auth-mfa-challengeandverify": { + "id": "auth-mfa-challengeandverify", + "title": "challengeAndVerify", + "slug": "auth-mfa-challengeandverify", + "product": "auth", + "type": "function" + }, + "auth-mfa-enroll": { + "id": "auth-mfa-enroll", + "title": "enroll", + "slug": "auth-mfa-enroll", + "product": "auth", + "type": "function" + }, + "auth-mfa-getauthenticatorassurancelevel": { + "id": "auth-mfa-getauthenticatorassurancelevel", + "title": "getAuthenticatorAssuranceLevel", + "slug": "auth-mfa-getauthenticatorassurancelevel", + "product": "auth", + "type": "function" + }, + "auth-mfa-unenroll": { + "id": "auth-mfa-unenroll", + "title": "unenroll", + "slug": "auth-mfa-unenroll", + "product": "auth", + "type": "function" + }, + "auth-mfa-verify": { + "id": "auth-mfa-verify", + "title": "verify", + "slug": "auth-mfa-verify", + "product": "auth", + "type": "function" + }, + "auth-passkey": { + "id": "auth-passkey", + "title": "Auth Passkey", + "slug": "auth-passkey", + "type": "function" + }, + "auth-passkey-delete": { + "id": "auth-passkey-delete", + "title": "delete", + "slug": "auth-passkey-delete", + "product": "auth", + "type": "function" + }, + "auth-passkey-list": { + "id": "auth-passkey-list", + "title": "list", + "slug": "auth-passkey-list", + "product": "auth", + "type": "function" + }, + "auth-passkey-startauthentication": { + "id": "auth-passkey-startauthentication", + "title": "startAuthentication", + "slug": "auth-passkey-startauthentication", + "product": "auth", + "type": "function" + }, + "auth-passkey-startregistration": { + "id": "auth-passkey-startregistration", + "title": "startRegistration", + "slug": "auth-passkey-startregistration", + "product": "auth", + "type": "function" + }, + "auth-passkey-update": { + "id": "auth-passkey-update", + "title": "update", + "slug": "auth-passkey-update", + "product": "auth", + "type": "function" + }, + "auth-passkey-verifyauthentication": { + "id": "auth-passkey-verifyauthentication", + "title": "verifyAuthentication", + "slug": "auth-passkey-verifyauthentication", + "product": "auth", + "type": "function" + }, + "auth-passkey-verifyregistration": { + "id": "auth-passkey-verifyregistration", + "title": "verifyRegistration", + "slug": "auth-passkey-verifyregistration", + "product": "auth", + "type": "function" + }, + "passkey-admin": { + "id": "passkey-admin", + "title": "Passkey Admin", + "slug": "passkey-admin", + "type": "function" + }, + "passkey-admin-deletepasskey": { + "id": "passkey-admin-deletepasskey", + "title": "deletePasskey", + "slug": "passkey-admin-deletepasskey", + "product": "auth", + "type": "function" + }, + "passkey-admin-listpasskeys": { + "id": "passkey-admin-listpasskeys", + "title": "listPasskeys", + "slug": "passkey-admin-listpasskeys", + "product": "auth", + "type": "function" + }, + "functions": { + "type": "category", + "title": "Edge Functions" + }, + "functions-invoke": { + "id": "functions-invoke", + "title": "invoke", + "slug": "functions-invoke", + "product": "edge-functions", + "type": "function" + }, + "realtime": { + "type": "category", + "title": "Realtime" + }, + "getchannels": { + "id": "getchannels", + "title": "getChannels", + "slug": "getchannels", + "product": "realtime", + "type": "function" + }, + "removeallchannels": { + "id": "removeallchannels", + "title": "removeAllChannels", + "slug": "removeallchannels", + "product": "realtime", + "type": "function" + }, + "removechannel": { + "id": "removechannel", + "title": "removeChannel", + "slug": "removechannel", + "product": "realtime", + "type": "function" + }, + "stream": { + "id": "stream", + "title": "stream", + "slug": "stream", + "product": "realtime", + "type": "function" + }, + "subscribe": { + "id": "subscribe", + "title": "subscribe", + "slug": "subscribe", + "product": "realtime", + "type": "function" + }, + "storage": { + "type": "category", + "title": "Storage" + }, + "file-buckets": { + "id": "file-buckets", + "title": "File Buckets", + "slug": "file-buckets", + "type": "function" + }, + "file-buckets-createbucket": { + "id": "file-buckets-createbucket", + "title": "createBucket", + "slug": "file-buckets-createbucket", + "product": "storage", + "type": "function" + }, + "file-buckets-createsignedurl": { + "id": "file-buckets-createsignedurl", + "title": "createSignedUrl", + "slug": "file-buckets-createsignedurl", + "product": "storage", + "type": "function" + }, + "file-buckets-deletebucket": { + "id": "file-buckets-deletebucket", + "title": "deleteBucket", + "slug": "file-buckets-deletebucket", + "product": "storage", + "type": "function" + }, + "file-buckets-download": { + "id": "file-buckets-download", + "title": "download", + "slug": "file-buckets-download", + "product": "storage", + "type": "function" + }, + "file-buckets-emptybucket": { + "id": "file-buckets-emptybucket", + "title": "emptyBucket", + "slug": "file-buckets-emptybucket", + "product": "storage", + "type": "function" + }, + "file-buckets-getbucket": { + "id": "file-buckets-getbucket", + "title": "getBucket", + "slug": "file-buckets-getbucket", + "product": "storage", + "type": "function" + }, + "file-buckets-getpublicurl": { + "id": "file-buckets-getpublicurl", + "title": "getPublicUrl", + "slug": "file-buckets-getpublicurl", + "product": "storage", + "type": "function" + }, + "file-buckets-list": { + "id": "file-buckets-list", + "title": "list", + "slug": "file-buckets-list", + "product": "storage", + "type": "function" + }, + "file-buckets-listbuckets": { + "id": "file-buckets-listbuckets", + "title": "listBuckets", + "slug": "file-buckets-listbuckets", + "product": "storage", + "type": "function" + }, + "file-buckets-move": { + "id": "file-buckets-move", + "title": "move", + "slug": "file-buckets-move", + "product": "storage", + "type": "function" + }, + "file-buckets-remove": { + "id": "file-buckets-remove", + "title": "remove", + "slug": "file-buckets-remove", + "product": "storage", + "type": "function" + }, + "file-buckets-update": { + "id": "file-buckets-update", + "title": "update", + "slug": "file-buckets-update", + "product": "storage", + "type": "function" + }, + "file-buckets-updatebucket": { + "id": "file-buckets-updatebucket", + "title": "updateBucket", + "slug": "file-buckets-updatebucket", + "product": "storage", + "type": "function" + }, + "file-buckets-upload": { + "id": "file-buckets-upload", + "title": "upload", + "slug": "file-buckets-upload", + "product": "storage", + "type": "function" + } + }, + "flat": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ], + "sections": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + { + "type": "category", + "title": "Database", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + { + "id": "using-filters", + "isFunc": false, + "title": "Using filters", + "slug": "using-filters", + "product": "database", + "type": "function", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + }, + { + "id": "using-modifiers", + "isFunc": false, + "title": "Using modifiers", + "slug": "using-modifiers", + "product": "database", + "type": "function", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + } + ] + }, + { + "type": "category", + "title": "Auth", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + { + "id": "auth-admin", + "isFunc": false, + "title": "Auth Admin", + "slug": "auth-admin", + "product": "auth", + "type": "function", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + }, + { + "id": "auth-mfa", + "isFunc": false, + "title": "Auth MFA", + "slug": "auth-mfa", + "product": "auth", + "type": "function", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + }, + { + "id": "auth-passkey", + "isFunc": false, + "title": "Auth Passkey", + "slug": "auth-passkey", + "product": "auth", + "type": "function", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + }, + { + "id": "passkey-admin", + "isFunc": false, + "title": "Passkey Admin", + "slug": "passkey-admin", + "product": "auth", + "type": "function", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]" + ] + } + ] + }, + { + "type": "category", + "title": "Edge Functions", + "items": [ + "[Circular]" + ] + }, + { + "type": "category", + "title": "Realtime", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + }, + { + "type": "category", + "title": "Storage", + "items": [ + { + "id": "file-buckets", + "isFunc": false, + "title": "File Buckets", + "slug": "file-buckets", + "product": "storage", + "type": "function", + "items": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + } + ] + } + ], + "functionsList": [ + { + "id": "initializing", + "title": "Initializing", + "description": "You can initialize Supabase with the static `initialize()` method of the `Supabase` class.\n\nThe Supabase client is your entrypoint to the rest of the Supabase functionality\nand is the easiest way to interact with everything we offer within the Supabase ecosystem.\n", + "params": [ + { + "name": "url", + "isOptional": false, + "type": "string", + "description": "The unique Supabase URL which is supplied when you create a new project in your project dashboard." + }, + { + "name": "publishableKey", + "isOptional": false, + "type": "string", + "description": "The publishable (anon) key supplied when you create a new project in your project dashboard. Use this for client-side apps. The deprecated `anonKey` parameter is still accepted but `publishableKey` takes precedence when both are supplied." + }, + { + "name": "headers", + "isOptional": true, + "type": "Map", + "description": "Custom header to be passed to the Supabase client." + }, + { + "name": "httpClient", + "isOptional": true, + "type": "Client", + "description": "Custom http client to be used by the Supabase client." + }, + { + "name": "authOptions", + "isOptional": true, + "type": "FlutterAuthClientOptions", + "description": "Options to change the Auth behaviors.", + "subContent": [ + { + "name": "authFlowType", + "isOptional": true, + "type": "AuthFlowType", + "description": "Whether to use the `pkce` flow or the `implicit` flow. Defaults to `pkce`." + }, + { + "name": "localStorage", + "isOptional": true, + "type": "LocalStorage", + "description": "Parameter to override the local storage to store auth tokens." + }, + { + "name": "autoRefreshToken", + "isOptional": true, + "type": "bool", + "description": "Whether to automatically refresh the token when it expires. Defaults to `true`." + } + ] + }, + { + "name": "postgrestOptions", + "isOptional": true, + "type": "PostgrestClientOptions", + "description": "Options to change the Postgrest behaviors.", + "subContent": [ + { + "name": "schema", + "isOptional": true, + "type": "String", + "description": "Schema to query with the Supabase client. Defaults to `public`." + } + ] + }, + { + "name": "realtimeClientOptions", + "isOptional": true, + "type": "RealtimeClientOptions", + "description": "Options to change the Realtime behaviors.", + "subContent": [ + { + "name": "logLevel", + "isOptional": true, + "type": "RealtimeLogLevel", + "description": "Level of realtime server logs to to be logged." + } + ] + }, + { + "name": "storageOptions", + "isOptional": true, + "type": "StorageClientOptions", + "description": "Options to change the Storage behaviors.", + "subContent": [ + { + "name": "retryAttempts", + "isOptional": true, + "type": "int", + "description": "The number of times to retry a failed upload request. Defaults to `0`." + }, + { + "name": "useNewHostname", + "isOptional": true, + "type": "bool", + "description": "Whether to rewrite legacy storage URLs to use the dedicated storage host (`.storage.supabase.co`). Set to `true` only if your project has the dedicated storage host enabled. Defaults to `false`." + } + ] + } + ], + "examples": [ + { + "id": "flutter-initialize", + "name": "For Flutter", + "code": "```dart\nFuture main() async {\n await Supabase.initialize(\n url: 'https://xyzcompany.supabase.co',\n publishableKey: 'your-publishable-key',\n );\n\n runApp(MyApp());\n}\n\n// Get a reference your Supabase client\nfinal supabase = Supabase.instance.client;\n```\n" + }, + { + "id": "for-other-dart-projects", + "name": "For other Dart projects", + "code": "```dart\nfinal supabase = SupabaseClient(\n 'https://xyzcompany.supabase.co',\n 'your-secret-key', // use your secret key for server-side usage\n);\n```\n" + } + ] + }, + { + "id": "delete", + "$ref": "supabase_flutter.delete", + "description": "Perform a DELETE on the table or view.\n", + "notes": "- `delete()` should always be combined with [Filters](/docs/reference/dart/using-filters) to target the item(s) you wish to delete.\n- If you use `delete()` with filters and you have RLS enabled, only rows visible through `SELECT` policies are deleted. Note that by default no rows are visible, so you need at least one `SELECT`/`ALL` policy that makes the rows visible.\n", + "examples": [ + { + "id": "delete-records", + "name": "Delete records", + "isSpotlight": true, + "code": "```dart\nawait supabase\n .from('countries')\n .delete()\n .eq('id', 1);\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'Mordor');\n```\n" + } + }, + { + "id": "delete-multiple-records", + "name": "Delete multiple records", + "code": "```dart\nawait supabase\n .from('countries')\n .delete()\n .inFilter('id', [1, 2, 3])\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'Mordor'), (2, 'Gondor'), (3, 'Rohan');\n```\n" + } + }, + { + "id": "fetch-delete-records", + "name": "Fetch deleted records", + "code": "```dart\nfinal List> data = await supabase\n .from('cities')\n .delete()\n .match({ 'id': 666 })\n .select();\n```\n" + } + ] + }, + { + "id": "insert", + "$ref": "supabase_flutter.insert", + "description": "Perform an INSERT into the table or view.\n", + "params": [ + { + "name": "values", + "isOptional": false, + "type": "Map or List>", + "description": "The values to insert. Pass an object to insert a single row or an array to insert multiple rows." + } + ], + "examples": [ + { + "id": "create-a-record", + "name": "Create a record", + "isSpotlight": true, + "code": "```dart\nawait supabase\n .from('cities')\n .insert({'name': 'The Shire', 'country_id': 554});\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n```\n" + }, + "hideCodeBlock": true + }, + { + "id": "fetch-inserted-data", + "name": "Fetch inserted record", + "code": "```dart\nfinal List> data =\n await supabase.from('cities').insert([\n {'name': 'The Shire', 'country_id': 554},\n {'name': 'Rohan', 'country_id': 555},\n ]).select();\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'Denmark'\n }\n]\n```\n" + }, + { + "id": "bulk-create", + "name": "Bulk create", + "code": "```dart\nawait supabase.from('cities').insert([\n {'name': 'The Shire', 'country_id': 554},\n {'name': 'Rohan', 'country_id': 555},\n]);\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n```\n" + }, + "response": "```json\nPostgrestException(\n 'code': '23505',\n 'details': 'Key (id)=(1) already exists.',\n 'hint': null,\n 'message': 'duplicate key value violates unique constraint \"countries_pkey\"'\n);\n```\n" + } + ] + }, + { + "id": "rpc", + "$ref": "supabase_flutter.rpc", + "description": "Perform a function call.\n\nYou can call Postgres functions as Remote Procedure Calls, logic in your database that you can execute from anywhere.\nFunctions are useful when the logic rarely changes—like for password resets and updates.\n", + "params": [ + { + "name": "fn", + "isOptional": false, + "type": "String", + "description": "The function name to call." + }, + { + "name": "params", + "isOptional": true, + "type": "Map", + "description": "The arguments to pass to the function call." + } + ], + "examples": [ + { + "id": "call-a-postgres-function-without-arguments", + "name": "Call a Postgres function without arguments", + "code": "```dart\nfinal data = await supabase\n .rpc('hello_world');\n```\n", + "data": { + "sql": "```sql\ncreate function hello_world() returns text as $$\n select 'Hello world';\n$$ language sql;\n```\n" + }, + "response": "```json\n'Hello world'\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + }, + { + "id": "call-a-postgres-function-with-arguments", + "name": "Call a Postgres function with arguments", + "code": "```dart\nfinal data = await supabase\n .rpc('echo_city', params: { 'say': '👋' });\n```\n", + "data": { + "sql": "```sql\ncreate function echo(say text) returns text as $$\n select say;\n$$ language sql;\n```\n" + }, + "response": "```json\n'👋'\n```\n", + "hideCodeBlock": true + }, + { + "id": "bulk-processing", + "name": "Bulk processing", + "code": "```dart\nfinal data = await supabase\n .rpc('add_one_each', params: { arr: [1, 2, 3] });\n```\n", + "data": { + "sql": "```sql\ncreate function add_one_each(arr int[]) returns int[] as $$\n select array_agg(n + 1) from unnest(arr) as n;\n$$ language sql;\n```\n" + }, + "response": "```json\n[\n 2,\n 3,\n 4,\n]\n```\n", + "description": "You can process large payloads by passing in an array as an argument.\n", + "hideCodeBlock": true + }, + { + "id": "call-a-postgres-function-with-filters", + "name": "Call a Postgres function with filters", + "code": "```dart\nfinal data = await supabase\n .rpc('list_stored_countries')\n .eq('id', 1)\n .single();\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'Mordor'),\n (2, 'Gondor');\n\ncreate function list_stored_countries() returns setof countries as $$\n select * from countries;\n$$ language sql;\n```\n" + }, + "response": "```json\n{\n 'id': 1,\n 'name': 'Mordor'\n}\n```\n", + "description": "Postgres functions that return tables can also be combined with [Filters](/docs/reference/dart/using-filters) and [Modifiers](/docs/reference/dart/using-modifiers).\n", + "hideCodeBlock": true + } + ] + }, + { + "id": "select", + "$ref": "supabase_flutter.select", + "description": "Perform a SELECT query on the table or view.\n", + "notes": "- By default, Supabase projects will return a maximum of 1,000 rows. This setting can be changed in Project API Settings. It's recommended that you keep it low to limit the payload size of accidental or malicious requests. You can use `range()` queries to paginate through your data.\n- `select()` can be combined with [Filters](/docs/reference/dart/using-filters)\n- `select()` can be combined with [Modifiers](/docs/reference/dart/using-modifiers)\n- `apikey` is a reserved keyword if you're using the [Supabase Platform](/docs/guides/platform) and [should be avoided as a column name](https://github.com/supabase/supabase/issues/5465).\n", + "params": [ + { + "name": "columns", + "isOptional": true, + "type": "String", + "description": "The columns to retrieve, separated by commas. Columns can be renamed when returned with `customName:columnName`" + } + ], + "examples": [ + { + "id": "getting-your-data", + "name": "Getting your data", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'violin'\n },\n {\n 'id': 2,\n 'name': 'viola'\n },\n {\n 'id': 3,\n 'name': 'cello'\n },\n]\n```\n" + }, + { + "id": "selecting-specific-columns", + "name": "Selecting specific columns", + "description": "You can select specific fields from your tables.", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('''\n name\n ''');\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'violin'\n },\n {\n 'name': 'viola'\n },\n {\n 'name': 'cello'\n },\n]\n```\n" + }, + { + "id": "query-referenced-tables", + "name": "Query referenced tables", + "description": "If your database has relationships, you can query related tables too.", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('''\n name,\n instruments ( name )\n ''');\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 2, 'flute'),\n (2, 1, 'violin');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'strings',\n 'instruments': [\n {\n 'name': 'violin'\n },\n ]\n },\n {\n 'name': 'woodwinds',\n 'instruments': [\n {\n 'name': 'flute'\n },\n ]\n },\n]\n```\n" + }, + { + "id": "query-referenced-tables-through-a-join-table", + "name": "Query referenced tables through a join table", + "code": "```dart\nfinal data = await supabase\n .from('users')\n .select('''\n name,\n teams (\n name\n )\n ''');\n ```\n", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text\n );\ncreate table\n teams (\n id int8 primary key,\n name text\n );\n-- join table\ncreate table\n users_teams (\n user_id int8 not null references users,\n team_id int8 not null references teams,\n -- both foreign keys must be part of a composite primary key\n primary key (user_id, team_id)\n );\n\ninsert into\n users (id, name)\nvalues\n (1, 'Kiran'),\n (2, 'Evan');\ninsert into\n teams (id, name)\nvalues\n (1, 'Green'),\n (2, 'Blue');\ninsert into\n users_teams (user_id, team_id)\nvalues\n (1, 1),\n (1, 2),\n (2, 2);\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'Kiran',\n 'teams': [\n {\n 'name': 'Green'\n },\n {\n 'name': 'Blue'\n },\n ]\n },\n {\n 'name': 'Evan',\n 'teams': [\n {\n 'name': 'Blue'\n }\n ]\n }\n]\n```\n", + "description": "If you're in a situation where your tables are **NOT** directly\nrelated, but instead are joined by a _join table_, you can still use\nthe `select()` method to query the related data. The join table needs\nto have the foreign keys as part of its composite primary key.\n", + "hideCodeBlock": true + }, + { + "id": "query-the-same-referenced-table-multiple-times", + "name": "Query the same referenced table multiple times", + "description": "Sometimes you will need to query the same referenced table twice. In\nthis case, you can use the name of the joined column to identify which\njoin you intend to use. For convenience, you can also give an alias\nfor each column. This example queries a `messages` table that is\njoined to a `users` table via `sender_id` and `receiver_id`. Since you\nwant the `name` for both sender and receiver, you can alias the\ncolumns as `to` and `from`.\n", + "code": "```dart\nfinal data = await supabase\n .from('messages')\n .select('''\n content,\n from:sender_id(name),\n to:receiver_id(name)\n ''');\n```\n", + "data": { + "sql": "```sql\n create table\n users (id int8 primary key, name text);\n\n create table\n messages (\n sender_id int8 not null references users,\n receiver_id int8 not null references users,\n content text\n );\n\n insert into\n users (id, name)\n values\n (1, 'Kiran'),\n (2, 'Evan');\n\n insert into\n messages (sender_id, receiver_id, content)\n values\n (1, 2, '👋');\n ```\n" + }, + "response": "```json\n[\n {\n 'content': '👋',\n 'from': {\n 'name': 'Kiran'\n },\n 'to': {\n 'name': 'Evan'\n }\n }\n]\n```\n" + }, + { + "id": "filtering-through-referenced-tables", + "name": "Filtering through referenced tables", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('name, orchestral_sections(*)')\n .eq('orchestral_sections.name', 'percussion');\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 2, 'flute'),\n (2, 1, 'violin');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'flute',\n 'orchestral_sections': null\n },\n {\n 'name': 'violin',\n 'orchestral_sections': null\n }\n]\n```\n", + "description": "If the filter on a referenced table's column is not satisfied, the referenced\ntable returns `[]` or `null` but the parent table is not filtered out.\nIf you want to filter out the parent table rows, use the `!inner` hint\n", + "hideCodeBlock": true + }, + { + "id": "querying-with-count-option", + "name": "Querying with count option", + "description": "You can get the number of rows by using the count option.\nAllowed values for count option are [CountOption.exact](https://postgrest.org/en/stable/api.html#exact-count), [CountOptionplanned](https://postgrest.org/en/stable/api.html#planned-count) and [CountOption.estimated](https://postgrest.org/en/stable/api.html#estimated-count).\n", + "code": "```dart\nfinal res = await supabase\n .from('instruments')\n .select('name')\n .count(CountOption.exact);\n\nfinal data = res.data;\nfinal count = res.count;\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\nPostgrestResponse(\n data: [\n {\n 'id': 1,\n 'name': 'violin'\n },\n {\n 'id': 2,\n 'name': 'viola'\n },\n {\n 'id': 3,\n 'name': 'cello'\n }\n ],\n count: 3\n);\n```\n" + }, + { + "id": "querying-json-data", + "name": "Querying JSON data", + "description": "If you have data inside of a JSONB column, you can apply select\nand query filters to the data values. Postgres offers a\n[number of operators](https://www.postgresql.org/docs/current/functions-json.html)\nfor querying JSON data. Also see\n[PostgREST docs](http://postgrest.org/en/v7.0.0/api.html#json-columns) for more details.\n", + "code": "```dart\nfinal data = await supabase\n .from('users')\n .select('''\n id, name,\n address->city\n ''');\n```\n", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Frodo', '{\"city\":\"Hobbiton\"}');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'Frodo',\n 'city': 'Hobbiton'\n }\n]\n```\n" + }, + { + "id": "querying-referenced-table-with-inner-join", + "name": "Querying referenced table with inner join", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('name, instruments!inner(name)')\n .eq('orchestral_sections.name', 'strings')\n .limit(1);\n```\n", + "data": { + "sql": "```sql\ncreate table orchestral_sections (\n \"id\" \"uuid\" primary key default \"extensions\".\"uuid_generate_v4\"() not null,\n \"name\" text\n);\n\ncreate table instruments (\n \"id\" \"uuid\" primary key default \"extensions\".\"uuid_generate_v4\"() not null,\n \"name\" text,\n \"section_id\" \"uuid\" references public.orchestral_sections on delete cascade\n);\n\nwith section as (\n insert into orchestral_sections (name)\n values ('strings') returning id\n)\ninsert into instruments (name, section_id) values\n('violin', (select id from section)),\n('viola', (select id from section)),\n('cello', (select id from section)),\n('double bass', (select id from section));\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'violin',\n 'orchestral_sections': {'name': 'strings'}\n }\n]\n```\n", + "description": "If you don't want to return the referenced table contents, you can leave the parenthesis empty.\nLike `.select('name, books!inner()')`.\n", + "hideCodeBlock": true + }, + { + "id": "switching-schemas-per-query", + "name": "Switching schemas per query", + "code": "```dart\nfinal data = await supabase\n .schema('myschema')\n .from('mytable')\n .select();\n```\n", + "data": { + "sql": "```sql\ncreate schema myschema;\n\ncreate table myschema.mytable (\n id uuid primary key default gen_random_uuid(),\n data text\n);\n\ninsert into myschema.mytable (data) values ('mydata');\n```\n" + }, + "response": "```json\n[\n {\n 'id': '4162e008-27b0-4c0f-82dc-ccaeee9a624d',\n 'data': 'mydata'\n }\n]\n```\n", + "description": "In addition to setting the schema during initialization, you can also switch schemas on a per-query basis.\nMake sure you've set up your [database privileges and API settings](/docs/guides/api/using-custom-schemas).\n", + "hideCodeBlock": true + } + ] + }, + { + "id": "update", + "$ref": "supabase_flutter.update", + "description": "Perform an UPDATE on the table or view.\n", + "notes": "- `update()` should always be combined with [Filters](/docs/reference/dart/using-filters) to target the item(s) you wish to update.\n", + "params": [ + { + "name": "values", + "isOptional": false, + "type": "Map", + "description": "The values to update with." + } + ], + "examples": [ + { + "id": "updating-your-data", + "name": "Update your data", + "isSpotlight": true, + "code": "```dart\nawait supabase\n .from('instruments')\n .update({ 'name': 'piano' })\n .eq('id', 1);\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'harpsichord');\n```\n" + } + }, + { + "id": "update-a-record-and-return-it", + "name": "Update a record and return it", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .update({ 'name': 'piano' })\n .eq('id', 1)\n .select();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'harpsichord');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'piano'\n }\n]\n```\n", + "hideCodeBlock": true + }, + { + "id": "updating-json-data", + "name": "Update JSON data", + "code": "```dart\nawait supabase\n .from('users')\n .update({\n 'address': {\n 'street': 'Melrose Place',\n 'postcode': 90210\n }\n })\n .eq('address->postcode', 90210);\n```\n", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Michael', '{ \"postcode\": 90210 }');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'Michael',\n 'address': {\n 'street': 'Melrose Place',\n 'postcode': 90210\n }\n }\n]\n```\n", + "description": "Postgres offers some\n[operators](/docs/guides/database/json#query-the-jsonb-data) for\nworking with JSON data. Currently, it is only possible to update the entire JSON document.\n" + } + ] + }, + { + "id": "upsert", + "$ref": "supabase_flutter.upsert", + "description": "Perform an UPSERT on the table or view. Depending on the column(s) passed to `onConflict`, `.upsert()` allows you to perform the equivalent of `.insert()` if a row with the corresponding `onConflict` columns doesn't exist, or if it does exist, perform an alternative action depending on `ignoreDuplicates`.\n", + "notes": "- Primary keys must be included in `values` to use upsert.\n", + "params": [ + { + "name": "values", + "isOptional": false, + "type": "Map or List>", + "description": "The values to upsert with. Pass a Map to upsert a single row or an array to upsert multiple rows." + }, + { + "name": "onConflict", + "isOptional": true, + "type": "String", + "description": "Comma-separated UNIQUE column(s) to specify how duplicate rows are determined. Two rows are duplicates if all the `onConflict` columns are equal." + }, + { + "name": "ignoreDuplicates", + "isOptional": true, + "type": "bool", + "description": "If `true`, duplicate rows are ignored. If `false`, duplicate rows are merged with existing rows." + }, + { + "name": "defaultToNull", + "isOptional": true, + "type": "bool", + "description": "Make missing fields default to `null`. Otherwise, use the default value for the column. This only applies when inserting new rows, not when merging with existing rows where ignoreDuplicates is set to false. This also only applies when doing bulk upserts." + } + ], + "examples": [ + { + "id": "upsert-your-data", + "name": "Upsert your data", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .upsert({ 'id': 1, 'name': 'piano' })\n .select();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'harpsichord');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'piano'\n }\n]\n```\n" + }, + { + "id": "bulk-upsert-your-data", + "name": "Bulk Upsert your data", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .upsert([\n { 'id': 1, 'name': 'piano' },\n { 'id': 2, 'name': 'harp' },\n ])\n .select();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'harpsichord');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'piano'\n },\n {\n 'id': 2,\n 'name': 'harp'\n }\n]\n```\n", + "hideCodeBlock": true + }, + { + "id": "upserting-into-tables-with-constraints", + "name": "Upserting into tables with constraints", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('users')\n .upsert({ 'id': 42, 'handle': 'saoirse', 'display_name': 'Saoirse' }, { onConflict: 'handle' })\n .select();\n```\n", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 generated by default as identity primary key,\n handle text not null unique,\n display_name text\n );\n\ninsert into\n users (id, handle, display_name)\nvalues\n (1, 'saoirse', null);\n```\n" + }, + "response": "```json\nPostgrestException(\n 'code': '23505',\n 'details': 'Key (handle)=(saoirse) already exists.',\n 'hint': null,\n 'message': \"duplicate key value violates unique constraint \"users_handle_key\"'\n)\n```\n", + "description": "In the above query, `upsert()` implicitly uses the `id`\n(primary key) column to determine conflicts. If there is no existing\nrow with the same `id`, `upsert()` inserts a new row, which\nwill fail in this case as there is already a row with `handle` `\"saoirse\"`.\nUsing the `onConflict` option, you can instruct `upsert()` to use\nanother column with a unique constraint to determine conflicts.\n" + } + ] + }, + { + "id": "using-filters", + "title": "Using filters", + "description": "Filters allow you to only return rows that match certain conditions.\n\nFilters can be used on `select()`, `update()`, `upsert()`, and `delete()` queries.\n\nIf a Database function returns a table response, you can also apply filters.\n", + "examples": [ + { + "id": "applying-filters", + "name": "Applying Filters", + "description": "Filters must be applied after any of `select()`, `update()`, `upsert()`,\n`delete()`, and `rpc()` and before\n[modifiers](/docs/reference/dart/using-modifiers).\n", + "code": "```dart\nfinal data = await supabase\n .from('cities')\n .select('name, country_id')\n .eq('name', 'The Shire'); // Correct\n\nfinal data = await supabase\n .from('cities')\n .eq('name', 'The Shire') // Incorrect\n .select('name, country_id');\n```\n" + }, + { + "id": "chaining-filters", + "name": "Chaining Filters", + "description": "Filters can be chained together to produce advanced queries as shown in the example code.\n", + "code": "```dart\nfinal data = await supabase\n .from('cities')\n .select('name, country_id')\n .gte('population', 1000)\n .lt('population', 10000)\n```\n" + }, + { + "id": "conditional-chaining", + "name": "Conditional Chaining", + "description": "Filters can be built up one step at a time and then executed as shown in the example code.\n", + "code": "```dart\nfinal filterByName = null;\nfinal filterPopLow = 1000;\nfinal filterPopHigh = 10000;\n\nvar query = supabase\n .from('cities')\n .select('name, country_id');\n\nif (filterByName != null) query = query.eq('name', filterByName);\nif (filterPopLow != null) query = query.gte('population', filterPopLow);\nif (filterPopHigh != null) query = query.lt('population', filterPopHigh);\n\nfinal data = await query;\n```\n" + }, + { + "id": "filter-by-value-within-json-column", + "name": "Filter by values within a JSON column", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Michael', '{ \"postcode\": 90210 }'),\n (2, 'Jane', null);\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'Michael',\n 'address': {\n 'postcode': 90210\n }\n },\n]\n```\n", + "code": "```dart\nfinal data = await supabase\n .from('users')\n .select()\n .eq('address->postcode', 90210);\n```\n" + }, + { + "id": "filter-referenced-tables", + "name": "Filter Referenced Tables", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('''\n name,\n instruments!inner (\n name\n )\n ''')\n .eq('instruments.name', 'flute');\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 2, 'flute'),\n (2, 1, 'violin');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'strings',\n 'instruments': [\n {\n 'name': 'flute'\n }\n ]\n },\n]\n```\n", + "description": "You can filter on referenced tables in your `select()` query using dot\nnotation.\n" + } + ] + }, + { + "id": "using-filters-containedby", + "$ref": "supabase_flutter.containedBy", + "description": "Only relevant for jsonb, array, and range columns. Match only rows where every element appearing in `column` is contained by `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The jsonb, array, or range column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The jsonb, array, or range value to filter with." + } + ], + "examples": [ + { + "id": "on-array-columns", + "name": "On array columns", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('classes')\n .select('name')\n .containedBy('days', ['monday', 'tuesday', 'wednesday', 'friday']);\n```\n", + "data": { + "sql": "```sql\ncreate table\n classes (\n id int8 primary key,\n name text,\n days text[]\n );\n\ninsert into\n classes (id, name, days)\nvalues\n (1, 'Chemistry', array['monday', 'friday']),\n (2, 'History', array['monday', 'wednesday', 'thursday']);\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'Chemistry'\n },\n]\n```\n", + "hideCodeBlock": true + }, + { + "id": "on-range-columns", + "name": "On range columns", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .containedBy('during', '[2000-01-01 00:00, 2000-01-01 23:59)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'room_name': 'Emerald',\n 'during': '[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")'\n },\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true + }, + { + "id": "on-jsonb-columns", + "name": "On `jsonb` columns", + "code": "```dart\nfinal data = await supabase\n .from('users')\n .select('name')\n .containedBy('address', {'postcode': 90210});\n```\n", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Michael', '{ \"postcode\": 90210, \"street\": \"Melrose Place\" }'),\n (2, 'Jane', '{\"postcode\": 90210}');\n```\n" + }, + "response": "```json\n [\n {\n 'name': 'Jane'\n },\n ]\n ```\n", + "hideCodeBlock": true + } + ] + }, + { + "id": "using-filters-contains", + "$ref": "supabase_flutter.contains", + "description": "Only relevant for jsonb, array, and range columns. Match only rows where `column` contains every element appearing in `value`.", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The jsonb, array, or range column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The jsonb, array, or range value to filter with." + } + ], + "examples": [ + { + "id": "on-array-columns", + "name": "On array columns", + "description": "Only relevant for jsonb, array, and range columns. Match only rows where `column` contains every element appearing in `value`.\n", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('issues')\n .select()\n .contains('tags', ['is:open', 'priority:low']);\n```\n", + "data": { + "sql": "```sql\ncreate table\n issues (\n id int8 primary key,\n title text,\n tags text[]\n );\n\ninsert into\n issues (id, title, tags)\nvalues\n (1, 'Cache invalidation is not working', array['is:open', 'severity:high', 'priority:low']),\n (2, 'Use better names', array['is:open', 'severity:low', 'priority:medium']);\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'title': 'Cache invalidation is not working',\n 'tags': ['is:open', 'severity:high', 'priority:low']\n },\n]\n```\n", + "hideCodeBlock": true + }, + { + "id": "on-range-columns", + "name": "On range columns", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .contains('during', '[2000-01-01 13:00, 2000-01-01 13:30)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'room_name': 'Emerald',\n 'during': '[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")'\n },\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true + }, + { + "id": "on-jsonb-columns", + "name": "On `jsonb` columns", + "code": "```dart\nfinal data = await supabase\n .from('users')\n .select('name')\n .contains('address', { 'street': 'Melrose Place' });\n```\n", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Michael', '{ \"postcode\": 90210, \"street\": \"Melrose Place\" }'),\n (2, 'Jane', '{\"postcode\": 90210}');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'Michael'\n },\n]\n```\n", + "hideCodeBlock": true + } + ] + }, + { + "id": "using-filters-eq", + "$ref": "supabase_flutter.eq", + "description": "Match only rows where `column` is equal to `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The value to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select()\n .eq('name', 'viola');\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 2,\n 'name': 'viola'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-filter", + "$ref": "supabase_flutter.filter", + "description": "Match only rows which satisfy the filter. This is an escape hatch - you should use the specific filter methods wherever possible.\n", + "notes": "`.filter()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values, so it should only be used as an escape hatch in case other filters don't work.\n```dart\n.filter('arraycol','cs','{\"a\",\"b\"}') // Use Postgres array {} and 'cs' for contains.\n.filter('rangecol','cs','(1,2]') // Use Postgres range syntax for range column.\n.filter('id','in','(6,7)') // Use Postgres list () and 'in' for in_ filter.\n.filter('id','cs','{${mylist.join(',')}}') // You can insert a Dart array list.\n```\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "operator", + "isOptional": false, + "type": "String", + "description": "The operator to filter with, following PostgREST syntax." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The value to filter with, following PostgREST syntax." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('characters')\n .select()\n .filter('name', 'in', '(\"Ron\",\"Dumbledore\")')\n```\n", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Harry'),\n (2, 'Hermione'),\n (3, 'Ron');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 3,\n 'name': 'Ron'\n }\n]\n```\n" + }, + { + "id": "with-update", + "name": "With update()", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .update({ 'name': 'piano' })\n .filter('name', 'in', '(\"harpsichord\",\"clavichord\")');\n```\n" + }, + { + "id": "with-delete", + "name": "With delete()", + "code": "```dart\nfinal data = await supabase\n .from('countries')\n .delete()\n .filter('name', 'in', '(\"Rohan\",\"Mordor\")');\n```\n" + }, + { + "id": "with-rpc", + "name": "With rpc()", + "code": "```dart\n// Only valid if the database function returns a table type.\nfinal data = await supabase\n .rpc('echo_all_countries')\n .filter('name', 'in', '(\"Rohan\",\"Mordor\")');\n```\n" + }, + { + "id": "on-a-referenced-table", + "name": "On a referenced table", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('''\n name,\n instruments!inner (\n name\n )\n ''')\n .filter('characters.name', 'eq', 'flute')\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 2, 'flute'),\n (2, 1, 'violin');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'woodwinds',\n 'instruments': [\n {\n 'name': 'flute'\n }\n ]\n }\n]\n```\n", + "hideCodeBlock": true + } + ] + }, + { + "id": "using-filters-gt", + "$ref": "supabase_flutter.gt", + "description": "Finds all rows whose value on the stated `column` is greater than the specified `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The value to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('countries')\n .select()\n .gt('id', 2);\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'Rohan'),\n (2, 'The Shire'),\n (3, 'Mordor');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 3,\n 'name': 'Mordor'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-gte", + "$ref": "supabase_flutter.gte", + "description": "Finds all rows whose value on the stated `column` is greater than or equal to the specified `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The value to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('countries')\n .select()\n .gte('id', 2);\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'Rohan'),\n (2, 'The Shire'),\n (3, 'Mordor');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 2,\n 'name': 'The Shire'\n },\n {\n 'id': 3,\n 'name': 'Mordor'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-ilike", + "$ref": "supabase_flutter.ilike", + "description": "Finds all rows whose value in the stated `column` matches the supplied `pattern` (case insensitive).\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "pattern", + "isOptional": false, + "type": "String", + "description": "The pattern to match with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('planets')\n .select()\n .ilike('name', '%ea%');\n```\n", + "data": { + "sql": "```sql\ncreate table\n planets (id int8 primary key, name text);\n\ninsert into\n planets (id, name)\nvalues\n (1, 'Mercury'),\n (2, 'Earth'),\n (3, 'Mars');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 2,\n 'name': 'Earth'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-infilter", + "$ref": "supabase_flutter.inFilter", + "description": "Finds all rows whose value on the stated `column` is found on the specified `values`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "values", + "isOptional": false, + "type": "List", + "description": "The List to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('characters')\n .select()\n .inFilter('name', ['Luke', 'Leia']);\n```\n", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Han'),\n (2, 'Luke'),\n (3, 'Leia');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 2,\n 'name': 'Luke'\n },\n {\n 'id': 3,\n 'name': 'Leia'\n },\n]\n```\n", + "description": "`inFilter()` is equivalent to `in()` in SDKs for other languages. It's named `inFilter()` in Dart to avoid a conflict with the `in` keyword in Dart.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-isfilter", + "$ref": "supabase_flutter.isFilter", + "description": "A check for exact equality (null, true, false), finds all rows whose value on the stated `column` exactly match the specified `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object?", + "description": "The value to filter with." + } + ], + "examples": [ + { + "id": "checking-nullness", + "name": "Checking for nullness, true or false", + "code": "```dart\nfinal data = await supabase\n .from('countries')\n .select()\n .isFilter('name', null);\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'null'),\n (2, null);\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'null'\n },\n]\n```\n", + "description": "Using the `eq()` filter doesn't work when filtering for `null`.\n\nInstead, you need to use `isFilter()`.\n\n`isFilter()` is equivalent to `is()` in SDKs for other languages. It's named `isFilter()` in Dart to avoid a conflict with the `is` keyword in Dart.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-like", + "$ref": "supabase_flutter.like", + "description": "Finds all rows whose value in the stated `column` matches the supplied `pattern` (case sensitive).\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "pattern", + "isOptional": false, + "type": "String", + "description": "The pattern to match with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('planets')\n .select()\n .like('name', '%Ea%');\n```\n", + "data": { + "sql": "```sql\ncreate table\n planets (id int8 primary key, name text);\n\ninsert into\n planets (id, name)\nvalues\n (1, 'Mercury'),\n (2, 'Earth'),\n (3, 'Mars');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 2,\n 'name': 'Earth'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-lt", + "$ref": "supabase_flutter.lt", + "description": "Finds all rows whose value on the stated `column` is less than the specified `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The value to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('countries')\n .select()\n .lt('id', 2);\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'Rohan'),\n (2, 'The Shire'),\n (3, 'Mordor');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'Rohan'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-lte", + "$ref": "supabase_flutter.lte", + "description": "Finds all rows whose value on the stated `column` is less than or equal to the specified `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The value to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('countries')\n .select()\n .lte('id', 2);\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'Rohan'),\n (2, 'The Shire'),\n (3, 'Mordor');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'Rohan'\n },\n {\n 'id': 2,\n 'name': 'The Shire'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-match", + "$ref": "supabase_flutter.match", + "description": "Finds all rows whose columns match the specified `query` object.\n", + "params": [ + { + "name": "query", + "isOptional": false, + "type": "Map", + "description": "The object to filter with, with column names as keys mapped to their filter values" + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select()\n .match({ 'id': 2, 'name': 'viola' });\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'viola'\n }\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-neq", + "$ref": "supabase_flutter.neq", + "description": "Finds all rows whose value on the stated `column` doesn't match the specified `value`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The value to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('id, name')\n .neq('name', 'viola');\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'violin'\n },\n {\n 'id': 3,\n 'name': 'cello'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-not", + "$ref": "supabase_flutter.not", + "description": "Finds all rows which doesn't satisfy the filter.\n", + "notes": "- `.not()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values.\n\n ```dart\n .not('name','eq','violin')\n .not('arraycol','cs','{\"a\",\"b\"}') // Use Postgres array {} for array column and 'cs' for contains.\n .not('rangecol','cs','(1,2]') // Use Postgres range syntax for range column.\n .not('id','in','(6,7)') // Use Postgres list () and 'in' instead of `inFilter`.\n .not('id','in','(${mylist.join(',')})') // You can insert a Dart list array.\n ```\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to filter on." + }, + { + "name": "operator", + "isOptional": false, + "type": "String", + "description": "The operator to be negated to filter with, following PostgREST syntax." + }, + { + "name": "value", + "isOptional": true, + "type": "Object", + "description": "The value to filter with, following PostgREST syntax." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "isSpotlight": false, + "code": "```dart\nfinal data = await supabase\n .from('countries')\n .select()\n .not('name', 'is', null)\n```\n", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'null'),\n (2, null);\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'null'\n }\n]\n```\n" + }, + { + "id": "with-update", + "name": "With update()", + "code": "```dart\nfinal data = await supabase\n .from('cities')\n .update({ 'name': 'Mordor' })\n .not('name', 'eq', 'Rohan');\n```\n" + }, + { + "id": "with-delete", + "name": "With delete()", + "code": "```dart\nfinal data = await supabase\n .from('cities')\n .delete()\n .not('name', 'eq', 'Mordor');\n```\n" + }, + { + "id": "with-rpc", + "name": "With rpc()", + "code": "```dart\n// Only valid if the database function returns a table type.\nfinal data = await supabase\n .rpc('echo_all_cities')\n .not('name', 'eq', 'Mordor');\n```\n" + } + ] + }, + { + "id": "using-filters-or", + "$ref": "supabase_flutter.or", + "description": "Finds all rows satisfying at least one of the filters.\n", + "notes": "- `.or()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values.\n\n ```dart\n .or('id.in.(6,7),arraycol.cs.{\"a\",\"b\"}') // Use Postgres list () and 'in' instead of `inFilter`. Array {} and 'cs' for contains.\n .or('id.in.(${mylist.join(',')}),arraycol.cs.{${mylistArray.join(',')}}')\t// You can insert a Dart list for list or array column.\n .or('id.in.(${mylist.join(',')}),rangecol.cs.(${mylistRange.join(',')}]')\t// You can insert a Dart list for list or range column.\n ```\n", + "params": [ + { + "name": "filters", + "isOptional": false, + "type": "String", + "description": "The filters to use, following PostgREST syntax" + }, + { + "name": "referencedTable", + "isOptional": true, + "type": "String", + "description": "Set this to filter on referenced tables instead of the parent table" + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('name')\n .or('id.eq.2,name.eq.cello');\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'viola'\n },\n {\n 'name': 'cello'\n }\n]\n```\n" + }, + { + "id": "use-or-with-and", + "name": "Use `or` with `and`", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('name')\n .or('id.gt.3,and(id.eq.1,name.eq.violin)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'violin'\n }\n]\n```\n" + }, + { + "id": "use-or-on-referenced-tables", + "name": "Use `or` on referenced tables", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('''\n name,\n instruments!inner (\n name\n )\n ''')\n .or('section_id.eq.1,name.eq.guzheng', referencedTable: 'instruments' );\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, book_id, name)\nvalues\n (1, 2, 'flute'),\n (2, 1, 'violin');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'strings',\n 'instruments': [\n {\n 'name': 'violin'\n }\n ]\n }\n]\n```\n", + "hideCodeBlock": true + } + ] + }, + { + "id": "using-filters-overlaps", + "$ref": "supabase_flutter.overlaps", + "description": "Only relevant for array and range columns. Match only rows where `column` and `value` have an element in common.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The array or range column to filter on." + }, + { + "name": "value", + "isOptional": false, + "type": "Object", + "description": "The array or range value to filter with." + } + ], + "examples": [ + { + "id": "on-array-columns", + "name": "On array columns", + "code": "```dart\nfinal data = await supabase\n .from('issues')\n .select('title')\n .overlaps('tags', ['is:closed', 'severity:high']);\n```\n", + "data": { + "sql": "```sql\ncreate table\n issues (\n id int8 primary key,\n title text,\n tags text[]\n );\n\ninsert into\n issues (id, title, tags)\nvalues\n (1, 'Cache invalidation is not working', array['is:open', 'severity:high', 'priority:low']),\n (2, 'Use better names', array['is:open', 'severity:low', 'priority:medium']);\n```\n" + }, + "response": "```json\n[\n {\n 'title': 'Cache invalidation is not working'\n },\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + }, + { + "id": "on-range-columns", + "name": "On range columns", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .overlaps('during', '[2000-01-01 12:45, 2000-01-01 13:15)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'room_name': 'Emerald',\n 'during': '[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")'\n },\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true + } + ] + }, + { + "id": "using-filters-rangeadjacent", + "$ref": "supabase_flutter.rangeAdjacent", + "description": "Only relevant for range columns. Match only rows where `column` is mutually exclusive to `range` and there can be no element between the two ranges.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The range column to filter on." + }, + { + "name": "range", + "isOptional": false, + "type": "String", + "description": "The range to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .rangeAdjacent('during', '[2000-01-01 12:00, 2000-01-01 13:00)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'room_name': 'Emerald',\n 'during': '[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")'\n },\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-rangegt", + "$ref": "supabase_flutter.rangeGt", + "description": "Only relevant for range columns. Match only rows where every element in `column` is greater than any element in `range`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The range column to filter on." + }, + { + "name": "range", + "isOptional": false, + "type": "String", + "description": "The range to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .rangeGt('during', '[2000-01-02 08:00, 2000-01-02 09:00)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 2,\n 'room_name': 'Topaz',\n 'during': '[\"2000-01-02 09:00:00\",\"2000-01-02 10:00:00\")'\n }\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-rangegte", + "$ref": "supabase_flutter.rangeGte", + "description": "Only relevant for range columns. Match only rows where every element in `column` is either contained in `range` or greater than any element in `range`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The range column to filter on." + }, + { + "name": "range", + "isOptional": false, + "type": "String", + "description": "The range to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .rangeGte('during', '[2000-01-02 08:30, 2000-01-02 09:30)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 2,\n 'room_name': 'Topaz',\n 'during': '[\"2000-01-02 09:00:00\",\"2000-01-02 10:00:00\")''\n },\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-rangelt", + "$ref": "supabase_flutter.rangeLt", + "description": "Only relevant for range columns. Match only rows where every element in `column` is less than any element in `range`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The range column to filter on." + }, + { + "name": "range", + "isOptional": false, + "type": "String", + "description": "The range to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .rangeLt('during', '[2000-01-01 15:00, 2000-01-01 16:00)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'room_name': 'Emerald',\n 'during': '[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")'\n },\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-rangelte", + "$ref": "supabase_flutter.rangeLte", + "description": "Only relevant for range columns. Match only rows where every element in `column` is either contained in `range` or less than any element in `range`.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The range column to filter on." + }, + { + "name": "range", + "isOptional": false, + "type": "String", + "description": "The range to filter with." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('reservations')\n .select()\n .rangeLte('during', '[2000-01-01 15:00, 2000-01-01 16:00)');\n```\n", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'room_name': 'Emerald',\n 'during': '[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")'\n },\n]\n```\n", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-filters-textsearch", + "$ref": "supabase_flutter.textSearch", + "description": "Finds all rows whose tsvector value on the stated `column` matches to_tsquery(query).\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The text or tsvector column to filter on." + }, + { + "name": "query", + "isOptional": false, + "type": "String", + "description": "The query text to match with." + }, + { + "name": "config", + "isOptional": true, + "type": "String", + "description": "The text search configuration to use." + }, + { + "name": "type", + "isOptional": true, + "type": "TextSearchType", + "description": "Change how the `query` text is interpreted." + } + ], + "examples": [ + { + "id": "text-search", + "name": "Text search", + "code": "```dart\nfinal data = await supabase\n .from('quotes')\n .select('catchphrase')\n .textSearch('content', \"'eggs' & 'ham'\",\n config: 'english'\n );\n```\n", + "data": { + "sql": "```sql\ncreate table texts (\n id bigint\n primary key\n generated always as identity,\n content text\n);\n\ninsert into texts (content) values\n ('Four score and seven years ago'),\n ('The road goes ever on and on'),\n ('Green eggs and ham')\n;\n```\n" + }, + "response": "```json\n[\n {\n 'content': 'Green eggs and ham'\n }\n]\n```\n" + }, + { + "id": "basic-normalization", + "name": "Basic normalization", + "description": "Uses PostgreSQL's `plainto_tsquery` function.", + "code": "```dart\nfinal data = await supabase\n .from('quotes')\n .select('catchphrase')\n .textSearch('catchphrase', \"'fat' & 'cat'\",\n type: TextSearchType.plain,\n config: 'english'\n );\n```\n" + }, + { + "id": "full-normalization", + "name": "Full normalization", + "description": "Uses PostgreSQL's `phraseto_tsquery` function.", + "code": "```dart\nfinal data = await supabase\n .from('quotes')\n .select('catchphrase')\n .textSearch('catchphrase', \"'fat' & 'cat'\",\n type: TextSearchType.phrase,\n config: 'english'\n );\n```\n" + }, + { + "id": "web-search", + "name": "Websearch", + "description": "Uses PostgreSQL's `websearch_to_tsquery` function.\nThis function will never raise syntax errors, which makes it possible to use raw user-supplied input for search, and can be used\nwith advanced operators.\n\n- `unquoted text`: text not inside quote marks will be converted to terms separated by & operators, as if processed by plainto_tsquery.\n- `\"quoted text\"`: text inside quote marks will be converted to terms separated by `<->` operators, as if processed by phraseto_tsquery.\n- `OR`: the word “or” will be converted to the | operator.\n- `-`: a dash will be converted to the ! operator.\n", + "code": "```dart\nfinal data = await supabase\n .from('quotes')\n .select('catchphrase')\n .textSearch('catchphrase', \"'fat or cat'\",\n type: TextSearchType.websearch,\n config: 'english'\n );\n```\n" + } + ] + }, + { + "id": "using-modifiers", + "title": "Using modifiers", + "description": "Filters work on the row level. That is, they allow you to return rows that\nonly match certain conditions without changing the shape of the rows.\nModifiers are everything that don't fit that definition—allowing you to\nchange the format of the response (e.g., returning a CSV string).\n\nModifiers must be specified after filters. Some modifiers only apply for\nqueries that return rows (e.g., `select()` or `rpc()` on a function that\nreturns a table response).\n" + }, + { + "id": "using-modifiers-csv", + "$ref": "supabase_flutter.csv", + "examples": [ + { + "id": "return-data-as-csv", + "name": "Return data as CSV", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select()\n .csv();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n'id,name\\n1,violin\\n2,viola\\n3,cello'\n```\n", + "description": "By default, the data is returned in JSON format, but can also be returned as Comma Separated Values.\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-modifiers-explain", + "$ref": "supabase_flutter.explain", + "description": "For debugging slow queries, you can get the [Postgres `EXPLAIN` execution plan](https://www.postgresql.org/docs/current/sql-explain.html) of a query\nusing the `explain()` method. This works on any query, even for `rpc()` or writes.\n\nExplain is not enabled by default as it can reveal sensitive information about your database.\nIt's best to only enable this for testing environments but if you wish to enable it for production you can provide additional protection by using a `pre-request` function.\n\nFollow the [Performance Debugging Guide](/docs/guides/database/debugging-performance) to enable the functionality on your project.\n", + "params": [ + { + "name": "analyze", + "isOptional": true, + "type": "bool", + "description": "If `true`, the query will be executed and the actual run time will be returned." + }, + { + "name": "verbose", + "isOptional": true, + "type": "bool", + "description": "If `true`, the query identifier will be returned and `data` will include the output columns of the query." + }, + { + "name": "settings", + "isOptional": true, + "type": "bool", + "description": "If `true`, include information on configuration parameters that affect query planning." + }, + { + "name": "buffers", + "isOptional": true, + "type": "bool", + "description": "If `true`, include information on buffer usage." + }, + { + "name": "wal", + "isOptional": true, + "type": "bool", + "description": "If `true`, include information on WAL record generation." + } + ], + "examples": [ + { + "id": "get-execution-plan", + "name": "Get the execution plan", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select()\n .explain();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```\nAggregate (cost=33.34..33.36 rows=1 width=112)\n -> Limit (cost=0.00..18.33 rows=1000 width=40)\n -> Seq Scan on instruments (cost=0.00..22.00 rows=1200 width=40)\n```\n", + "description": "By default, the data is returned in TEXT format, but can also be returned as JSON by using the `format` parameter.\n", + "hideCodeBlock": true, + "isSpotlight": true + }, + { + "id": "get-execution-plan-with-analyze-and-verbose", + "name": "Get the execution plan with analyze and verbose", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select()\n .explain(analyze:true, verbose:true);\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```\nAggregate (cost=33.34..33.36 rows=1 width=112) (actual time=0.041..0.041 rows=1 loops=1)\n Output: NULL::bigint, count(ROW(instruments.id, instruments.name)), COALESCE(json_agg(ROW(instruments.id, instruments.name)), '[]'::json), NULLIF(current_setting('response.headers'::text, true), ''::text), NULLIF(current_setting('response.status'::text, true), ''::text)\n -> Limit (cost=0.00..18.33 rows=1000 width=40) (actual time=0.005..0.006 rows=3 loops=1)\n Output: instruments.id, instruments.name\n -> Seq Scan on public.instruments (cost=0.00..22.00 rows=1200 width=40) (actual time=0.004..0.005 rows=3 loops=1)\n Output: instruments.id, instruments.name\nQuery Identifier: -4730654291623321173\nPlanning Time: 0.407 ms\nExecution Time: 0.119 ms\n```\n", + "description": "By default, the data is returned in TEXT format, but can also be returned as JSON by using the `format` parameter.\n", + "hideCodeBlock": true, + "isSpotlight": false + } + ] + }, + { + "id": "using-modifiers-limit", + "$ref": "supabase_flutter.limit", + "description": "Limits the result with the specified count.\n", + "params": [ + { + "name": "count", + "isOptional": false, + "type": "int", + "description": "The maximum number of rows to return." + }, + { + "name": "referencedTable", + "isOptional": true, + "type": "int", + "description": "Set this to limit rows of referenced tables instead of the parent table." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('name')\n .limit(1);\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'violin'\n }\n]\n```\n" + }, + { + "id": "on-a-referenced-table", + "name": "On a referenced table", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('''\n name,\n instruments (\n name\n )\n ''')\n .limit(1, referencedTable: 'instruments');\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 1, 'violin'),\n (2, 1, 'viola');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'strings',\n 'instruments': [\n {\n 'name': 'violin'\n }\n ]\n }\n]\n```\n" + } + ] + }, + { + "id": "using-modifiers-maxaffected", + "$ref": "supabase_flutter.maxAffected", + "description": "Sets the maximum number of rows that can be affected by the query. Only effective with PATCH and DELETE operations. Requires PostgREST v13 or higher.\n\nWhen the limit is exceeded, the query will fail with an error. This provides a safety mechanism to prevent accidentally affecting more rows than intended.\n", + "notes": "- This method is only effective with UPDATE and DELETE operations.\n- Requires PostgREST v13 or higher on your Supabase instance.\n- If the number of affected rows exceeds the limit, the query will fail and no rows will be modified.\n", + "params": [ + { + "name": "count", + "isOptional": false, + "type": "int", + "description": "The maximum number of rows that can be affected by the query." + } + ], + "examples": [ + { + "id": "with-update", + "name": "With update()", + "isSpotlight": true, + "code": "```dart\nawait supabase\n .from('users')\n .update({'active': false})\n .eq('status', 'inactive')\n .maxAffected(5);\n```\n", + "description": "Limit the number of rows that can be updated. If more than 5 rows match the filter, the operation will fail.\n" + }, + { + "id": "with-delete", + "name": "With delete()", + "code": "```dart\nawait supabase\n .from('users')\n .delete()\n .eq('active', false)\n .maxAffected(10);\n```\n", + "description": "Limit the number of rows that can be deleted. If more than 10 rows match the filter, the operation will fail.\n" + }, + { + "id": "with-select", + "name": "With select()", + "code": "```dart\nfinal data = await supabase\n .from('users')\n .update({'status': 'INACTIVE'})\n .eq('id', 1)\n .maxAffected(1)\n .select();\n```\n", + "description": "Combine maxAffected with select() to limit affected rows and return the updated data.\n" + } + ] + }, + { + "id": "using-modifiers-maybesingle", + "$ref": "supabase_flutter.maybeSingle", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select()\n .eq('name', 'guzheng')\n .maybeSingle();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\nnull\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-modifiers-order", + "$ref": "supabase_flutter.order", + "description": "Orders the result with the specified column.\n", + "params": [ + { + "name": "column", + "isOptional": false, + "type": "String", + "description": "The column to order by." + }, + { + "name": "ascending", + "isOptional": true, + "type": "bool", + "description": "Whether to order in ascending order. Default is `false`." + }, + { + "name": "nullsFirst", + "isOptional": true, + "type": "bool", + "description": "Whether to order nulls first. Default is `false`." + }, + { + "name": "referencedTable", + "isOptional": true, + "type": "String", + "description": "Specify the referenced table when ordering by a column in an embedded resource." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('id, name')\n .order('id', ascending: false);\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'id': 3,\n 'name': 'cello'\n },\n {\n 'id': 2,\n 'name': 'viola'\n },\n {\n 'id': 1,\n 'name': 'viola'\n }\n]\n```\n" + }, + { + "id": "on-a-referenced-table", + "name": "On a referenced table", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('''\n name,\n instruments (\n name\n )\n ''')\n .order('name', referencedTable: 'instruments', ascending: false);\n ```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 1, 'harp'),\n (2, 1, 'violin');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'strings',\n 'instruments': [\n {\n 'name': 'violin'\n },\n {\n 'name': 'harp'\n }\n ]\n },\n {\n 'name': 'woodwinds',\n 'instruments': []\n }\n]\n```\n" + }, + { + "id": "order-parent-table-by-a-referenced-table", + "name": "Order parent table by a referenced table", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('''\n name,\n section:orchestral_sections (\n name\n )\n ''')\n .order('section(name)', ascending: true)\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 1, 'violin'),\n (2, 2, 'flute');\n```\n" + }, + "response": "```json\n[\n {\n \"name\": \"violin\",\n \"orchestral_sections\": { \"name\": \"strings\" }\n },\n {\n \"name\": \"flute\",\n \"orchestral_sections\": { \"name\": \"woodwinds\" }\n }\n]\n```\n" + } + ] + }, + { + "id": "using-modifiers-range", + "$ref": "supabase_flutter.range", + "description": "Limits the result to rows within the specified range, inclusive.\n", + "params": [ + { + "name": "from", + "isOptional": false, + "type": "int", + "description": "The starting index from which to limit the result." + }, + { + "name": "to", + "isOptional": false, + "type": "int", + "description": "The last index to which to limit the result." + }, + { + "name": "referencedTable", + "isOptional": true, + "type": "String", + "description": "Set this to limit rows of referenced tables instead of the parent table." + } + ], + "examples": [ + { + "id": "with-select", + "name": "With select()", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('name')\n .range(0, 1);\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'violin'\n },\n {\n 'name': 'viola'\n }\n]\n```\n" + } + ] + }, + { + "id": "using-modifiers-select", + "$ref": "supabase_flutter.select", + "examples": [ + { + "id": "with-upsert", + "name": "With `upsert()`", + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .upsert({ 'id': 1, 'name': 'piano' })\n .select();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'harpsichord');\n```\n" + }, + "response": "```json\n[\n {\n \"id\": 1,\n \"name\": \"piano\"\n }\n]\n```\n", + "hideCodeBlock": true, + "isSpotlight": true + } + ] + }, + { + "id": "using-modifiers-single", + "$ref": "supabase_flutter.single", + "description": "Retrieves only one row from the result. Result must be one row (e.g. using limit), otherwise this will result in an error.\n", + "examples": [ + { + "id": "with-select", + "name": "With select()", + "isSpotlight": true, + "code": "```dart\nfinal data = await supabase\n .from('instruments')\n .select('name')\n .limit(1)\n .single();\n```\n", + "data": { + "sql": "```sql\ncreate table\n instruments (id int8 primary key, name text);\n\ninsert into\n instruments (id, name)\nvalues\n (1, 'violin'),\n (2, 'viola'),\n (3, 'cello');\n```\n" + }, + "response": "```json\n{\n 'name': 'violin'\n}\n```\n" + } + ] + }, + { + "id": "auth-currentsession", + "$ref": "supabase_flutter.currentSession", + "description": "Returns the session data, if there is an active session.\n", + "examples": [ + { + "id": "get-the-session-data", + "name": "Get the session data", + "isSpotlight": true, + "code": "```dart\nfinal Session? session = supabase.auth.currentSession;\n```\n", + "response": "```json\nSession(\n accessToken: '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n);\n```\n" + } + ] + }, + { + "id": "auth-currentuser", + "$ref": "supabase_flutter.currentUser", + "description": "Returns the user data, if there is a logged in user.\n", + "examples": [ + { + "name": "Get the logged in user", + "isSpotlight": true, + "code": "```dart\nfinal User? user = supabase.auth.currentUser;\n```\n", + "response": "```json\nUser(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n);\n```\n" + } + ] + }, + { + "id": "auth-getuseridentities", + "$ref": "supabase_flutter.getUserIdentities", + "description": "Gets all the identities linked to a user.\n", + "notes": "- The user needs to be signed in to call `getUserIdentities()`.\n", + "examples": [ + { + "id": "get-user-identities", + "name": "Returns a list of identities linked to the user", + "isSpotlight": true, + "code": "```dart\nfinal identities = await supabase.auth.getUserIdentities();\n```\n", + "response": "```json\n[\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '2024-01-01T00:00:00Z',\n userId: '2024-01-01T00:00:00Z',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n];\n```\n" + } + ] + }, + { + "id": "auth-linkidentity", + "$ref": "supabase_flutter.linkIdentity", + "description": "Links an oauth identity to an existing user. This method supports the PKCE flow.\n", + "notes": "- The **Enable Manual Linking** option must be enabled from your [project's authentication settings](/dashboard/project/_/auth/providers).\n- The user needs to be signed in to call `linkIdentity()`.\n- If the candidate identity is already linked to the existing user or another user, `linkIdentity()` will fail.\n", + "params": [ + { + "name": "provider", + "isOptional": false, + "type": "OAuthProvider", + "description": "The provider to link the identity to." + }, + { + "name": "redirectTo", + "isOptional": true, + "type": "String", + "description": "The URL to redirect the user to after they sign in with the third-party provider." + }, + { + "name": "scopes", + "isOptional": true, + "type": "String", + "description": "A list of scopes to request from the third-party provider." + }, + { + "name": "authScreenLaunchMode", + "isOptional": true, + "type": "LaunchMode", + "description": "The launch mode for the auth screen. Defaults to `LaunchMode.platformDefault`." + }, + { + "name": "queryParams", + "isOptional": true, + "type": "Map", + "description": "Additional query parameters to be passed to the OAuth flow." + } + ], + "examples": [ + { + "id": "link-identity", + "name": "Link an identity to a user", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.linkIdentity(OAuthProvider.google);\n```\n" + } + ] + }, + { + "id": "auth-linkidentitywithidtoken", + "$ref": "supabase_flutter.linkIdentityWithIdToken", + "description": "Links an identity to an existing user using an ID token obtained from a third-party OAuth provider. This allows linking identities using native OAuth flows (Google, Apple, Facebook, etc.) similar to `signInWithIdToken()` but for linking rather than signing in.\n", + "notes": "- The **Enable Manual Linking** option must be enabled from your [project's authentication settings](/dashboard/project/_/auth/providers).\n- The user needs to be signed in to call `linkIdentityWithIdToken()`.\n- Supports the same OAuth providers as `signInWithIdToken()`: Google, Apple, Facebook, Kakao, and Keycloak.\n- If the candidate identity is already linked to another user, the operation will fail.\n", + "params": [ + { + "name": "provider", + "isOptional": false, + "type": "OAuthProvider", + "description": "The OAuth provider to link the identity from." + }, + { + "name": "idToken", + "isOptional": false, + "type": "String", + "description": "The identity token obtained from the third-party provider." + }, + { + "name": "accessToken", + "isOptional": true, + "type": "String", + "description": "Access token obtained from the third-party provider. Required for Google sign in." + }, + { + "name": "nonce", + "isOptional": true, + "type": "String", + "description": "Raw nonce value used to perform the third-party sign in. Required for Apple sign-in." + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification." + } + ], + "examples": [ + { + "id": "link-google-identity", + "name": "Link Google identity", + "isSpotlight": true, + "description": "Link a Google identity to the currently signed-in user using native Google Sign-In.\n", + "code": "```dart\nimport 'package:google_sign_in/google_sign_in.dart';\nimport 'package:supabase_flutter/supabase_flutter.dart';\n\nconst webClientId = '';\nconst iosClientId = '';\n\nfinal GoogleSignIn googleSignIn = GoogleSignIn(\n clientId: iosClientId,\n serverClientId: webClientId,\n);\nfinal googleUser = await googleSignIn.signIn();\nfinal googleAuth = await googleUser!.authentication;\nfinal accessToken = googleAuth.accessToken;\nfinal idToken = googleAuth.idToken;\n\nif (accessToken == null) {\n throw 'No Access Token found.';\n}\nif (idToken == null) {\n throw 'No ID Token found.';\n}\n\nfinal response = await supabase.auth.linkIdentityWithIdToken(\n provider: OAuthProvider.google,\n idToken: idToken,\n accessToken: accessToken,\n);\n```\n" + }, + { + "id": "link-apple-identity", + "name": "Link Apple identity", + "description": "Link an Apple identity to the currently signed-in user using native Apple Sign In.\n", + "code": "```dart\nimport 'package:sign_in_with_apple/sign_in_with_apple.dart';\nimport 'package:supabase_flutter/supabase_flutter.dart';\nimport 'package:crypto/crypto.dart';\n\nfinal rawNonce = supabase.auth.generateRawNonce();\nfinal hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();\n\nfinal credential = await SignInWithApple.getAppleIDCredential(\n scopes: [\n AppleIDAuthorizationScopes.email,\n AppleIDAuthorizationScopes.fullName,\n ],\n nonce: hashedNonce,\n);\n\nfinal idToken = credential.identityToken;\nif (idToken == null) {\n throw const AuthException(\n 'Could not find ID Token from generated credential.',\n );\n}\n\nfinal response = await supabase.auth.linkIdentityWithIdToken(\n provider: OAuthProvider.apple,\n idToken: idToken,\n nonce: rawNonce,\n);\n```\n" + } + ] + }, + { + "id": "auth-onauthstatechange", + "$ref": "supabase_flutter.onAuthStateChange", + "description": "Receive a notification every time an auth event happens.\n", + "notes": "- **You must provide an `onError` handler.** Network errors (e.g. an offline token refresh) are emitted as stream errors. If no `onError` is provided, Dart rethrows them as unhandled zone exceptions, crashing the app.\n- Auth event types: `initialSession`, `signedIn`, `signedOut`, `passwordRecovery`, `tokenRefreshed`, `userUpdated`, `userDeleted`, `mfaChallengeVerified`\n", + "examples": [ + { + "id": "listen-to-auth-changes", + "name": "Listen to auth changes", + "isSpotlight": true, + "code": "```dart\nfinal authSubscription = supabase.auth.onAuthStateChange.listen(\n (data) {\n final AuthChangeEvent event = data.event;\n final Session? session = data.session;\n // handle event\n },\n onError: (error, stackTrace) {\n // Network errors (e.g. offline) are emitted here.\n // Handle or log them to avoid an unhandled exception crash.\n },\n);\n```\n" + }, + { + "id": "listen-to-a-specific-event", + "name": "Listen to a specific event", + "code": "```dart\nfinal authSubscription = supabase.auth.onAuthStateChange.listen(\n (data) {\n final AuthChangeEvent event = data.event;\n if (event == AuthChangeEvent.signedIn) {\n // handle signIn\n }\n },\n onError: (error, stackTrace) {\n // Handle or log network / auth errors here.\n },\n);\n```\n" + }, + { + "id": "unsubscribe-from-auth-subscription", + "name": "Unsubscribe from auth subscription", + "code": "```dart\nfinal authSubscription = supabase.auth.onAuthStateChange.listen(\n (data) {},\n onError: (error, stackTrace) {},\n);\n\nauthSubscription.cancel();\n```\n" + } + ] + }, + { + "id": "auth-reauthenticate", + "$ref": "supabase_flutter.reauthenticate", + "notes": "- This method is used together with `updateUser()` when a user's password needs to be updated.\n- This method sends a nonce to the user's email. If the user doesn't have a confirmed email address, the method sends the nonce to the user's confirmed phone number instead.\n", + "examples": [ + { + "id": "send-reauthentication-nonce", + "name": "Send reauthentication nonce", + "description": "Sends a reauthentication nonce to the user's email or phone number.", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.reauthenticate();\n```\n" + } + ] + }, + { + "id": "auth-refreshsession", + "$ref": "supabase_flutter.refreshSession", + "notes": "- This method will refresh and return a new session whether the current one is expired or not.\n", + "examples": [ + { + "id": "refresh-session-using-the-current-session", + "name": "Refresh session using the current session", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.refreshSession();\nfinal session = res.session;\n```\n", + "response": "```json\nAuthResponse(\n user: User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n session: Session(\n accessToken: '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ),\n);\n```\n" + } + ] + }, + { + "id": "auth-registerpasskey", + "$ref": "supabase_flutter.registerPasskey", + "notes": "Registers a new passkey (WebAuthn credential) for the signed in user.\n- Available on `supabase_flutter` 2.15.0 and later as an extension on `GoTrueClient`.\n- Drives the full WebAuthn ceremony end to end: starts the registration with the Supabase server, prompts the user to create a credential on the device, and verifies it with the server.\n- Requires a signed in (non-anonymous) user. If the user has verified MFA factors, the session has to be at `aal2` to manage passkeys.\n- For native flows or custom UI, use the lower-level [`auth.passkey`](/docs/reference/dart/auth-passkey-api) namespace instead.\n- Passkeys are a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys.\n", + "examples": [ + { + "id": "register-passkey", + "name": "Register a passkey for the current user", + "isSpotlight": true, + "code": "```dart\nfinal Passkey passkey = await supabase.auth.registerPasskey();\n```\n" + } + ] + }, + { + "id": "auth-resend", + "$ref": "supabase_flutter.resend", + "notes": "- Resends a signup confirmation, email change, or phone change email to the user.\n- Passwordless sign-ins can be resent by calling the `signInWithOtp()` method again.\n- Password recovery emails can be resent by calling the `resetPasswordForEmail()` method again.\n- This method only resend an email or phone OTP to the user if an initial signup, email change, or phone change request was made.\n", + "examples": [ + { + "id": "resend-email-signup-confirmation", + "name": "Resend an email signup confirmation", + "description": "Resends the email signup confirmation to the user", + "isSpotlight": true, + "code": "```dart\nfinal ResendResponse res = await supabase.auth.resend(\n type: OtpType.signup,\n email: 'email@example.com',\n);\n```\n" + } + ] + }, + { + "id": "auth-resetpasswordforemail", + "$ref": "supabase_flutter.resetPasswordForEmail", + "description": "Sends a reset request to an email address.\n", + "notes": "Sends a password reset request to an email address. When the user clicks the reset link in the email they are redirected back to your application. Prompt the user for a new password and call auth.updateUser():\n\n```dart\nawait supabase.auth.resetPasswordForEmail(\n 'sample@email.com',\n redirectTo: kIsWeb ? null : 'io.supabase.flutter://reset-callback/',\n);\n```\n", + "examples": [ + { + "id": "reset-password", + "name": "Reset password for Flutter", + "isSpotlight": true, + "code": "`redirectTo` is used to open the app via deeplink when user opens the password reset email.\n```dart\nawait supabase.auth.resetPasswordForEmail(\n 'sample@email.com',\n redirectTo: kIsWeb ? null : 'io.supabase.flutter://reset-callback/',\n);\n```\n" + } + ] + }, + { + "id": "auth-setsession", + "$ref": "supabase_flutter.setSession", + "notes": "- `setSession()` takes in a refresh token and uses it to get a new session.\n- The refresh token can only be used once to obtain a new session.\n- [Refresh token rotation](/docs/guides/cli/config#auth.enable_refresh_token_rotation) is enabled by default on all projects to guard against replay attacks.\n- You can configure the [`REFRESH_TOKEN_REUSE_INTERVAL`](/docs/guides/cli/config#auth.refresh_token_reuse_interval) which provides a short window in which the same refresh token can be used multiple times in the event of concurrency or offline issues.\n", + "params": [ + { + "name": "refreshToken", + "isOptional": false, + "type": "String", + "description": "Refresh token to use to get a new session." + }, + { + "name": "accessToken", + "isOptional": true, + "type": "String", + "description": "Optional access token to set along with the refresh token." + } + ], + "examples": [ + { + "id": "refresh-the-session", + "name": "Refresh the session", + "description": "Sets the session data from refresh_token and returns current session or an error if the refresh_token is invalid.", + "isSpotlight": true, + "code": "```dart\nfinal refreshToken = supabase.currentSession?.refreshToken ?? '';\nfinal AuthResponse response = await supabase.auth.setSession(refreshToken);\n\nfinal session = res.session;\n```\n" + }, + { + "id": "set-session-with-access-token", + "name": "Set session with access token", + "description": "You can optionally provide an access token along with the refresh token.", + "isSpotlight": false, + "code": "```dart\nfinal AuthResponse response = await supabase.auth.setSession(\n refreshToken,\n accessToken: accessToken,\n);\n```\n", + "response": "```json\nAuthResponse(\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n session: Session(\n accessToken: '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ),\n);\n```\n" + } + ] + }, + { + "id": "auth-signinanonymously", + "$ref": "supabase_flutter.signInAnonymously", + "description": "Creates an anonymous user.\n", + "notes": "- Returns an anonymous user\n- It is recommended to set up captcha for anonymous sign-ins to prevent abuse. You can pass in the captcha token in the `options` param.\n", + "params": [ + { + "name": "data", + "isOptional": true, + "type": "Map", + "description": "The user's metadata to be stored in the user's object." + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification." + } + ], + "examples": [ + { + "id": "sign-in-anonymously", + "name": "Create an anonymous user", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.signInAnonymously();\n```\n", + "response": "```json\nAuthResponse(\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: '',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {},\n userMetadata: {},\n identities: [],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n isAnonymous: true,\n ),\n session: Session(\n accessToken: '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: '',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {},\n userMetadata: {},\n identities: [],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n isAnonymous: true,\n ),\n ),\n);\n```\n" + }, + { + "id": "sign-in-anonymously-with-user-metadata", + "name": "Create an anonymous user with custom user metadata", + "isSpotlight": false, + "code": "```dart\nawait supabase.auth.signInAnonymously(\n data: {'hello': 'world'},\n);\n```\n" + } + ] + }, + { + "id": "auth-signinwithidtoken", + "$ref": "supabase_flutter.signInWithIdToken", + "description": "Allows you to perform native Google, Apple, and Facebook sign in by combining it with [google_sign_in](https://pub.dev/packages/google_sign_in), [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple), or [flutter_facebook_auth](https://pub.dev/packages/flutter_facebook_auth) packages.\n", + "params": [ + { + "name": "provider", + "isOptional": false, + "type": "OAuthProvider", + "description": "The provider to perform the sign in with." + }, + { + "name": "idToken", + "isOptional": false, + "type": "String", + "description": "The identity token obtained from the third-party provider." + }, + { + "name": "accessToken", + "isOptional": true, + "type": "String", + "description": "Access token obtained from the third-party provider. Required for Google sign in." + }, + { + "name": "nonce", + "isOptional": true, + "type": "String", + "description": "Raw nonce value used to perform the third-party sign in. Required for Apple sign-in." + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification." + } + ], + "examples": [ + { + "id": "sign-in-with-google", + "name": "Native Google sign in", + "isSpotlight": true, + "description": "You can perform native Google sign in on Android and iOS using [google_sign_in](https://pub.dev/packages/google_sign_in).\nFor platform specific settings, follow the instructions in the package README.\n\nFirst, create client IDs for your app. You need to create a web client ID as well to perform Google sign-in with Supabase.\n\n- [Steps to obtain web client ID](https://developers.google.com/identity/sign-in/android/start-integrating#configure_a_project)\n- [Steps to obtain Android client ID](https://developers.google.com/identity/sign-in/android/start-integrating#configure_a_project)\n- [Steps to obtain iOS client ID](https://developers.google.com/identity/sign-in/ios/start-integrating#get_an_oauth_client_id)\n\nAdd the web client ID to the `Authentication -> Providers -> Google -> Client IDs` section in your Supabase dashboard\n", + "code": "```dart\nimport 'package:google_sign_in/google_sign_in.dart';\nimport 'package:supabase_flutter/supabase_flutter.dart';\n\nconst webClientId = '';\n\nconst iosClientId = '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n ...\n },\n userMetadata: {\n ...\n },\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n provider: 'google',\n ...\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ),\n);\n```\n" + }, + { + "id": "sign-in-with-apple", + "name": "Native Apple Sign in", + "description": "You need to [register your app ID with Apple](https://developer.apple.com/help/account/manage-identifiers/register-an-app-id/) with the `Sign In with Apple` capability selected, and add the bundle ID to your Supabase dashboard in `Authentication -> Providers -> Apple` before performing native Apple sign in.", + "code": "```dart\nimport 'package:sign_in_with_apple/sign_in_with_apple.dart';\nimport 'package:supabase_flutter/supabase_flutter.dart';\nimport 'package:crypto/crypto.dart';\n\n/// Performs Apple sign in on iOS or macOS\nfinal rawNonce = supabase.auth.generateRawNonce();\nfinal hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();\n\nfinal credential = await SignInWithApple.getAppleIDCredential(\n scopes: [\n AppleIDAuthorizationScopes.email,\n AppleIDAuthorizationScopes.fullName,\n ],\n nonce: hashedNonce,\n);\n\nfinal idToken = credential.identityToken;\nif (idToken == null) {\n throw const AuthException(\n 'Could not find ID Token from generated credential.',\n );\n}\n\nfinal response = await supabase.auth.signInWithIdToken(\n provider: OAuthProvider.apple,\n idToken: idToken,\n nonce: rawNonce,\n);\n```\n" + }, + { + "id": "sign-in-with-facebook", + "name": "Native Facebook Sign in", + "description": "You can perform native Facebook sign in using [flutter_facebook_auth](https://pub.dev/packages/flutter_facebook_auth).\n\nFirst, set up your Facebook app in the [Facebook Developer Console](https://developers.facebook.com) and configure it in your Supabase dashboard under `Authentication -> Providers -> Facebook`.\n", + "code": "```dart\nimport 'package:flutter_facebook_auth/flutter_facebook_auth.dart';\nimport 'package:supabase_flutter/supabase_flutter.dart';\n\nFuture signInWithFacebook() async {\n final LoginResult result = await FacebookAuth.instance.login(\n permissions: ['public_profile', 'email'],\n );\n\n if (result.status == LoginStatus.success) {\n final accessToken = result.accessToken!.tokenString;\n\n final response = await supabase.auth.signInWithIdToken(\n provider: OAuthProvider.facebook,\n idToken: accessToken,\n );\n } else {\n throw const AuthException(\n 'Facebook login failed: ${result.status}',\n );\n }\n}\n```\n" + } + ] + }, + { + "id": "auth-signinwithoauth", + "$ref": "supabase_flutter.signInWithOAuth", + "description": "Signs the user in using third-party OAuth providers.\n", + "notes": "- This method is used for signing in using a third-party provider.\n- Supabase supports many different [third-party providers](https://supabase.com/docs/guides/auth#providers).\n", + "params": [ + { + "name": "provider", + "isOptional": false, + "type": "OAuthProvider", + "description": "The OAuth provider to use for signing in." + }, + { + "name": "redirectTo", + "isOptional": true, + "type": "String", + "description": "The URL to redirect the user to after they sign in with the third-party provider." + }, + { + "name": "scopes", + "isOptional": true, + "type": "String", + "description": "A list of scopes to request from the third-party provider." + }, + { + "name": "authScreenLaunchMode", + "isOptional": true, + "type": "LaunchMode", + "description": "The launch mode for the auth screen. Defaults to `LaunchMode.platformDefault`." + }, + { + "name": "queryParams", + "isOptional": true, + "type": "Map", + "description": "Additional query parameters to be passed to the OAuth flow." + } + ], + "examples": [ + { + "id": "sign-in-using-a-third-party-provider", + "name": "Sign in using a third-party provider", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.signInWithOAuth(\n OAuthProvider.github,\n redirectTo: kIsWeb ? null : 'my.scheme://my-host', // Optionally set the redirect link to bring back the user via deeplink.\n authScreenLaunchMode:\n kIsWeb ? LaunchMode.platformDefault : LaunchMode.externalApplication, // Launch the auth screen in a new webview on mobile.\n);\n```\n" + }, + { + "id": "sign-in-with-custom-provider", + "name": "Sign in with a custom OIDC provider", + "description": "`OAuthProvider` is now a class instead of an enum, allowing you to use custom OIDC providers by passing a custom provider string.\n", + "isSpotlight": false, + "code": "```dart\nawait supabase.auth.signInWithOAuth(\n OAuthProvider('custom:my-oidc-provider'),\n redirectTo: kIsWeb ? null : 'my.scheme://my-host',\n);\n```\n" + }, + { + "id": "sign-in-using-a-third-party-provider-with-redirect", + "name": "With `redirectTo`", + "description": "Specify the redirect link to bring back the user via deeplink.\nNote that `redirectTo` should be null for Flutter Web.\n", + "code": "```dart\nawait supabase.auth.signInWithOAuth(\n OAuthProvider.github,\n redirectTo: kIsWeb ? null : 'io.supabase.flutter://reset-callback/',\n);\n```\n" + }, + { + "id": "sign-in-with-scopes", + "name": "With scopes", + "description": "If you need additional data from an OAuth provider, you can include a space-separated list of scopes in your request to get back an OAuth provider token.\nYou may also need to specify the scopes in the provider's OAuth app settings, depending on the provider.\n", + "code": "```dart\nawait supabase.auth.signInWithOAuth(\n OAuthProvider.github,\n scopes: 'repo gist notifications'\n);\n...\n// after user comes back from signin flow\n\nfinal Session? session = supabase.auth.currentSession;\nfinal String? oAuthToken = session?.providerToken;\n```\n" + } + ] + }, + { + "id": "auth-signinwithotp", + "$ref": "supabase_flutter.signInWithOtp", + "notes": "- Requires either an email or phone number.\n- This method is used for passwordless sign-ins where an OTP is sent to the user's email or phone number.\n- If you're using an email, you can configure whether you want the user to receive a magiclink or an OTP.\n- If you're using phone, you can configure whether you want the user to receive an OTP.\n- The magic link's destination URL is determined by the [`SITE_URL`](https://supabase.com/docs/guides/auth/redirect-urls). You can modify the `SITE_URL` or add additional redirect urls in [your project](https://supabase.com/dashboard/project/_/auth/url-configuration).\n", + "params": [ + { + "name": "email", + "isOptional": true, + "type": "String", + "description": "Email address to send the magic link or OTP to." + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "Phone number to send the OTP to." + }, + { + "name": "emailRedirectTo", + "isOptional": true, + "type": "String", + "description": "The URL to redirect the user to after they click on the magic link." + }, + { + "name": "shouldCreateUser", + "isOptional": true, + "type": "bool", + "description": "If set to false, this method will not create a new user. Defaults to true." + }, + { + "name": "data", + "isOptional": true, + "type": "Map", + "description": "The user's metadata to be stored in the user's object." + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification." + }, + { + "name": "channel", + "isOptional": true, + "type": "OtpChannel", + "description": "Messaging channel to use (e.g. whatsapp or sms). Defaults to `OtpChannel.sms`." + } + ], + "examples": [ + { + "id": "sign-in-with-email", + "name": "Sign in with email.", + "isSpotlight": true, + "description": "The user will be sent an email which contains either a magiclink or an OTP or both. By default, a given user can only request an OTP once every 60 seconds.\nYou can pass `emailRedirectTo` with a dynamic link to bring the users back to your app after they click on the magic link.\n", + "code": "```dart\nawait supabase.auth.signInWithOtp(\n email: 'example@email.com',\n emailRedirectTo: kIsWeb ? null : 'io.supabase.flutter://signin-callback/',\n);\n```\n", + "response": "```json\nAuthResponse(\n user: null,\n session: null,\n);\n```\n" + }, + { + "id": "sign-in-with-sms-otp", + "name": "Sign in with SMS OTP.", + "description": "The user will be sent a SMS which contains an OTP. By default, a given user can only request an OTP once every 60 seconds.", + "code": "```dart\nawait supabase.auth.signInWithOtp(\n phone: '+13334445555',\n);\n```\n" + }, + { + "id": "sign-in-with-whatsapp-otp", + "name": "Sign in with WhatsApp OTP", + "isSpotlight": false, + "description": "The user will be sent a WhatsApp message which contains an OTP. By default, a given user can only request an OTP once every 60 seconds. Note that a user will need to have a valid WhatsApp account that is linked to Twilio in order to use this feature.", + "code": "```dart\nawait supabase.auth.signInWithOtp(\n phone: '+13334445555',\n channel: OtpChannel.whatsapp,\n);\n```\n" + } + ] + }, + { + "id": "auth-signinwithpasskey", + "$ref": "supabase_flutter.signInWithPasskey", + "notes": "Signs the user in with a passkey (WebAuthn).\n- Available on `supabase_flutter` 2.15.0 and later as an extension on `GoTrueClient`.\n- Drives the full WebAuthn ceremony end to end: starts the challenge with the Supabase server, prompts the user for biometrics or a security key via the platform passkey API, and verifies the credential with the server.\n- Does not require an existing session. On success the session is persisted and an `AuthChangeEvent.signedIn` event is fired.\n- For native flows or custom UI, use the lower-level [`auth.passkey`](/docs/reference/dart/auth-passkey-api) namespace instead.\n- Passkeys are a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys.\n- Platform setup the library cannot perform (Associated Domains on iOS/macOS, Digital Asset Links on Android, the `passkeys` web SDK on web) is documented in the `supabase_flutter` package README.\n", + "params": [ + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "Captcha token to be used for captcha verification." + } + ], + "examples": [ + { + "id": "sign-in-with-passkey", + "name": "Sign in with a passkey", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.signInWithPasskey();\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n" + } + ] + }, + { + "id": "auth-signinwithpassword", + "$ref": "supabase_flutter.signInWithPassword", + "description": "Log in an existing user using email or phone number with password.\n", + "notes": "- Requires either an email and password or a phone number and password.\n", + "params": [ + { + "name": "email", + "isOptional": true, + "type": "String", + "description": "User's email address to be used for email authentication." + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "User's phone number to be used for phone authentication." + }, + { + "name": "password", + "isOptional": false, + "type": "String", + "description": "Password to be used for authentication." + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification." + } + ], + "examples": [ + { + "id": "sign-in-with-email-and-password", + "name": "Sign in with email and password", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.signInWithPassword(\n email: 'example@email.com',\n password: 'example-password',\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n", + "response": "```json\nAuthResponse(\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n session: Session(\n accessToken: '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ),\n);\n```\n" + }, + { + "id": "sign-in-with-phone-and-password", + "name": "Sign in with phone and password", + "code": "```dart\nfinal AuthResponse res = await supabase.auth.signInWithPassword(\n phone: '+13334445555',\n password: 'example-password',\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n" + } + ] + }, + { + "id": "auth-signinwithsso", + "$ref": "supabase_flutter.signInWithSSO", + "notes": "- Before you can call this method you need to [establish a connection](/docs/guides/auth/sso/auth-sso-saml#managing-saml-20-connections) to an identity provider. Use the [CLI commands](/docs/reference/cli/supabase-sso) to do this.\n- If you've associated an email domain to the identity provider, you can use the `domain` property to start a sign-in flow.\n- In case you need to use a different way to start the authentication flow with an identity provider, you can use the `providerId` property. For example:\n - Mapping specific user email addresses with an identity provider.\n - Using different hints to identify the correct identity provider, like a company-specific page, IP address or other tracking information.\n", + "params": [ + { + "name": "providerId", + "isOptional": true, + "type": "String", + "description": "The ID of the SSO provider to use for signing in." + }, + { + "name": "domain", + "isOptional": true, + "type": "String", + "description": "The email domain to use for signing in." + }, + { + "name": "redirectTo", + "isOptional": true, + "type": "String", + "description": "The URL to redirect the user to after they sign in with the third-party provider." + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification." + }, + { + "name": "launchMode", + "isOptional": true, + "type": "LaunchMode", + "description": "The launch mode for the auth screen. Defaults to `LaunchMode.platformDefault`." + } + ], + "examples": [ + { + "id": "sign-in-with-domain", + "name": "Sign in with email domain", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.signInWithSSO(\n domain: 'company.com',\n);\n```\n" + }, + { + "id": "sign-in-with-provider-uuid", + "name": "Sign in with provider UUID", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.signInWithSSO(\n providerId: '21648a9d-8d5a-4555-a9d1-d6375dc14e92',\n);\n```\n" + } + ] + }, + { + "id": "auth-signout", + "$ref": "supabase_flutter.signOut", + "description": "Signs out the current user, if there is a logged in user.\n", + "notes": "- In order to use the `signOut()` method, the user needs to be signed in first.\n", + "params": [ + { + "name": "scope", + "isOptional": true, + "type": "SignOutScope", + "description": "Whether to sign out from all devices or just the current device. Defaults to `SignOutScope.local`." + } + ], + "examples": [ + { + "id": "sign-out", + "name": "Sign out", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.signOut();\n```\n" + } + ] + }, + { + "id": "auth-signup", + "$ref": "supabase_flutter.signUp", + "description": "Creates a new user.\n", + "notes": "- By default, the user needs to verify their email address before logging in. To turn this off, disable **Confirm email** in [your project](https://supabase.com/dashboard/project/_/auth/providers).\n- **Confirm email** determines if users need to confirm their email address after signing up.\n - If **Confirm email** is enabled, a `user` is returned but `session` is null.\n - If **Confirm email** is disabled, both a `user` and a `session` are returned.\n- When the user confirms their email address, they are redirected to the [`SITE_URL`](https://supabase.com/docs/guides/auth/redirect-urls) by default. You can modify your `SITE_URL` or add additional redirect URLs in [your project](https://supabase.com/dashboard/project/_/auth/url-configuration).\n- If signUp() is called for an existing confirmed user:\n - When both **Confirm email** and **Confirm phone** (even when phone provider is disabled) are enabled in [your project](/dashboard/project/_/auth/providers), an obfuscated/fake user object is returned.\n - When either **Confirm email** or **Confirm phone** (even when phone provider is disabled) is disabled, the error message, `User already registered` is returned.\n", + "params": [ + { + "name": "email", + "isOptional": true, + "type": "String", + "description": "User's email address to be used for email authentication." + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "User's phone number to be used for phone authentication." + }, + { + "name": "password", + "isOptional": false, + "type": "String", + "description": "Password to be used for authentication." + }, + { + "name": "emailRedirectTo", + "isOptional": true, + "type": "String", + "description": "The URL to redirect the user to after they confirm their email address." + }, + { + "name": "data", + "isOptional": true, + "type": "Map", + "description": "The user's metadata to be stored in the user's object." + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification." + }, + { + "name": "channel", + "isOptional": true, + "type": "OtpChannel", + "description": "Messaging channel to use (e.g. whatsapp or sms). Defaults to `OtpChannel.sms`." + } + ], + "examples": [ + { + "id": "sign-up", + "name": "Sign up with an email and password", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.signUp(\n email: 'example@email.com',\n password: 'example-password',\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n", + "response": "```json\n// Some fields may be null if \"confirm email\" is enabled.\nAuthResponse(\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n session: Session(\n accessToken: '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ),\n);\n```\n" + }, + { + "id": "sign-up-phone", + "name": "Sign up with a phone number and password (SMS)", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.signUp(\n phone: '123456789',\n password: 'example-password',\n channel: OtpChannel.sms,\n);\n```\n" + }, + { + "id": "sign-up-with-metadata", + "name": "Sign up with additional metadata", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.signUp(\n email: 'example@email.com',\n password: 'example-password',\n data: {'username': 'my_user_name'},\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n" + }, + { + "id": "sign-up-with-redirect-url", + "name": "Sign up with redirect URL", + "isSpotlight": true, + "description": "- See [redirect URLs and wildcards](/docs/guides/auth#redirect-urls-and-wildcards) to add additional redirect URLs to your project.\n", + "code": "```dart\nfinal AuthResponse res = await supabase.auth.signUp(\n email: 'example@email.com',\n password: 'example-password',\n emailRedirectTo: 'com.supabase.myapp://callback',\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n" + } + ] + }, + { + "id": "auth-unlinkidentity", + "$ref": "supabase_flutter.unlinkIdentity", + "description": "Unlinks an identity from a user by deleting it. The user will no longer be able to sign in with that identity once it's unlinked.\n", + "notes": "- The **Enable Manual Linking** option must be enabled from your [project's authentication settings](/dashboard/project/_/auth/providers).\n- The user needs to be signed in to call `unlinkIdentity()`.\n- The user must have at least 2 identities in order to unlink an identity.\n- The identity to be unlinked must belong to the user.\n", + "params": [ + { + "name": "identity", + "isOptional": false, + "type": "UserIdentity", + "description": "The user identity to unlink." + } + ], + "examples": [ + { + "id": "unlink-identity", + "name": "Unlink an identity", + "isSpotlight": true, + "code": "```dart\n// retrieve all identities linked to a user\nfinal identities = await supabase.auth.getUserIdentities();\n\n// find the google identity\nfinal googleIdentity = identities.firstWhere(\n (element) => element.provider == 'google',\n);\n\n// unlink the google identity\nawait supabase.auth.unlinkIdentity(googleIdentity);\n```\n" + } + ] + }, + { + "id": "auth-updateuser", + "$ref": "supabase_flutter.updateUser", + "description": "Updates user data for a logged in user.\n", + "notes": "- In order to use the `updateUser()` method, the user needs to be signed in first.\n- By default, email updates sends a confirmation link to both the user's current and new email.\nTo only send a confirmation link to the user's new email, disable **Secure email change** in your project's [email auth provider settings](https://supabase.com/dashboard/project/_/auth/providers).\n", + "params": [ + { + "name": "attributes", + "isOptional": false, + "type": "UserAttributes", + "description": "Attributes to update for the user.", + "subContent": [ + { + "name": "email", + "isOptional": true, + "type": "String", + "description": "The new email address for the user." + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "The new phone number for the user." + }, + { + "name": "password", + "isOptional": true, + "type": "String", + "description": "The new password for the user." + }, + { + "name": "data", + "isOptional": true, + "type": "Map", + "description": "The user's metadata to be stored in the user's object." + }, + { + "name": "nonce", + "isOptional": true, + "type": "String", + "description": "The nonce sent for reauthentication if the user's password is to be updated." + } + ] + }, + { + "name": "emailRedirectTo", + "isOptional": true, + "type": "String", + "description": "The URI to redirect the user to after the email is updated." + } + ], + "examples": [ + { + "id": "update-the-email-for-an-authenticated-user", + "name": "Update the email for an authenticated user", + "description": "Sends a \"Confirm Email Change\" email to the new email address.", + "isSpotlight": true, + "code": "```dart\nfinal UserResponse res = await supabase.auth.updateUser(\n UserAttributes(\n email: 'example@email.com',\n ),\n);\nfinal User? updatedUser = res.user;\n```\n", + "response": "```json\nUserResponse(\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n);\n```\n" + }, + { + "id": "update-the-password-for-an-authenticated-user", + "name": "Update the password for an authenticated user", + "isSpotlight": false, + "code": "```dart\nfinal UserResponse res = await supabase.auth.updateUser(\n UserAttributes(\n password: 'new password',\n ),\n);\nfinal User? updatedUser = res.user;\n```\n" + }, + { + "id": "update-the-users-metadata", + "name": "Update the user's metadata", + "isSpotlight": true, + "code": "```dart\nfinal UserResponse res = await supabase.auth.updateUser(\n UserAttributes(\n data: { 'hello': 'world' },\n ),\n);\nfinal User? updatedUser = res.user;\n```\n" + }, + { + "id": "update-password-with-reauthentication", + "name": "Update the user's password with a nonce", + "description": "If \"Secure password change\" is enabled, updating the user's password requires a nonce. The nonce is sent to the user's email or phone number.", + "isSpotlight": true, + "code": "```dart\nsupabase.auth.updateUser(UserAttributes(\n email: 'example@email.com',\n nonce: '123456',\n));\n```\n" + } + ] + }, + { + "id": "auth-verifyotp", + "$ref": "supabase_flutter.verifyOtp", + "notes": "- The `verifyOtp` method takes in different verification types. If a phone number is used, the type can either be `sms` or `phone_change`. If an email address is used, the type can be one of the following: `email`, `recovery`, `invite` or `email_change` (`signup` and `magiclink` types are deprecated).\n- The verification type used should be determined based on the corresponding auth method called before `verifyOtp` to sign up or sign in a user.\n", + "params": [ + { + "name": "token", + "isOptional": false, + "type": "String", + "description": "The token that user was sent to their email or mobile phone" + }, + { + "name": "type", + "isOptional": false, + "type": "OtpType", + "description": "Type of the OTP to verify" + }, + { + "name": "email", + "isOptional": true, + "type": "String", + "description": "Email address that the OTP was sent to" + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "Phone number that the OTP was sent to" + }, + { + "name": "redirectTo", + "isOptional": true, + "type": "String", + "description": "URI to redirect the user to after the OTP is verified" + }, + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "The captcha token to be used for captcha verification" + }, + { + "name": "tokenHash", + "isOptional": true, + "type": "String", + "description": "Token used in an email link" + } + ], + "examples": [ + { + "id": "verify-signup-one-time-password(otp)", + "name": "Verify Signup One-Time Password (OTP)", + "isSpotlight": false, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.verifyOTP(\n type: OtpType.signup,\n token: token,\n phone: '+13334445555',\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n", + "response": "```json\nAuthResponse(\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n session: Session(\n accessToken: '',\n tokenType: 'bearer',\n expiresIn: 3600,\n refreshToken: '',\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ),\n);\n```\n" + }, + { + "id": "verify-sms-one-time-password(otp)", + "name": "Verify SMS One-Time Password (OTP)", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.verifyOTP(\n type: OtpType.sms,\n token: '111111',\n phone: '+13334445555',\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n" + } + ] + }, + { + "id": "auth-admin", + "title": "Auth Admin", + "notes": "- Any method under the `supabase.auth.admin` namespace requires a `secret` key.\n- These methods are considered admin methods and should be called on a trusted server. Never expose your `secret` key in the Flutter app.\n", + "examples": [ + { + "id": "create-auth-admin-client", + "name": "Create server-side auth client", + "isSpotlight": true, + "code": "```dart\nfinal supabase = SupabaseClient(supabaseUrl, secretKey);\n```\n" + } + ] + }, + { + "id": "auth-admin-createuser", + "$ref": "supabase_flutter.createUser", + "notes": "Creates a new user.\n- To confirm the user's email address or phone number, set `email_confirm` or `phone_confirm` to true. Both arguments default to false.\n- `createUser()` will not send a confirmation email to the user. You can use [`inviteUserByEmail()`](/docs/reference/dart/auth-admin-inviteuserbyemail) if you want to send them an email invite instead.\n- If you are sure that the created user's email or phone number is legitimate and verified, you can set the `email_confirm` or `phone_confirm` param to `true`.\n", + "params": [ + { + "name": "attributes", + "isOptional": false, + "type": "AdminUserAttributes", + "description": "Attributes to create the user with.", + "subContent": [ + { + "name": "email", + "isOptional": false, + "type": "String", + "description": "The email address of the user." + }, + { + "name": "password", + "isOptional": false, + "type": "String", + "description": "The password of the user." + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "The phone number of the user." + }, + { + "name": "userMetadata", + "isOptional": true, + "type": "Map", + "description": "A custom data object to store the user's metadata. This maps to the `auth.users.user_metadata` column." + }, + { + "name": "appMetadata", + "isOptional": true, + "type": "Map", + "description": "A custom data object to store the user's application specific metadata. This maps to the `auth.users.app_metadata` column." + }, + { + "name": "emailConfirm", + "isOptional": true, + "type": "bool", + "description": "Whether to confirm the user's email address." + }, + { + "name": "phoneConfirm", + "isOptional": true, + "type": "bool", + "description": "Whether to confirm the user's phone number." + }, + { + "name": "banDuration", + "isOptional": true, + "type": "String", + "description": "Determines how long a user is banned for. Some possible durations include '300ms', '2h45m'." + } + ] + } + ], + "examples": [ + { + "id": "create-a-new-user-with-custom-user-metadata", + "name": "With custom user metadata", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.admin.createUser(AdminUserAttributes(\n email: 'user@email.com',\n password: 'password',\n userMetadata: {'name': 'Yoda'},\n));\n```\n", + "response": "```json\nUserResponse(\n user: User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'user@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {'name': 'Yoda'},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'user@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n);\n```\n" + }, + { + "id": "auto-confirm-the-users-email", + "name": "Auto-confirm the user's email", + "code": "```dart\nfinal res = await supabase.auth.admin.createUser(AdminUserAttributes(\n email: 'user@email.com',\n emailConfirm: true,\n));\n```\n" + }, + { + "id": "auto-confirm-the-users-phone-number", + "name": "Auto-confirm the user's phone number", + "code": "```dart\nfinal res = await supabase.auth.admin.createUser(AdminUserAttributes(\n phone: '1234567890',\n phoneConfirm: true,\n));\n```\n" + } + ] + }, + { + "id": "auth-admin-deleteuser", + "$ref": "supabase_flutter.deleteUser", + "notes": "Delete a user.\n- The `deleteUser()` method requires the user's ID, which maps to the `auth.users.id` column.\n", + "params": [ + { + "name": "id", + "isOptional": false, + "type": "String", + "description": "ID of the user to be deleted." + } + ], + "examples": [ + { + "id": "removes-a-user", + "name": "Removes a user", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.admin\n .deleteUser('715ed5db-f090-4b8c-a067-640ecee36aa0');\n```\n" + } + ] + }, + { + "id": "auth-admin-generatelink", + "$ref": "supabase_flutter.generateLink", + "notes": "Generates email links and OTPs. This will not send links or OTPs to the end user. This function is for custom admin functionality.\n- The following types can be passed into `generateLink()`: `signup`, `magiclink`, `invite`, `recovery`, `emailChangeCurrent`, `emailChangeNew`, `phoneChange`.\n- `generateLink()` only generates the email link for `email_change_email` if the \"Secure email change\" setting is enabled under the \"Email\" provider in your Supabase project.\n- `generateLink()` handles the creation of the user for `signup`, `invite` and `magiclink`.\n", + "params": [ + { + "name": "type", + "isOptional": false, + "type": "GenerateLinkType", + "description": "The type of invite link to generate." + }, + { + "name": "email", + "isOptional": false, + "type": "String", + "description": "Email address of the user to invite." + }, + { + "name": "password", + "isOptional": true, + "type": "String", + "description": "Password for the user. Required for `signup` type." + }, + { + "name": "redirectTo", + "isOptional": true, + "type": "String", + "description": "URI to redirect the user to after they open the invite link." + }, + { + "name": "data", + "isOptional": true, + "type": "Map", + "description": "A custom data object to store the user's metadata. This maps to the `auth.users.user_metadata` column." + } + ], + "examples": [ + { + "id": "generate-a-signup-link", + "name": "Generate a signup link", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.admin.generateLink(\n type: GenerateLinkType.signup,\n email: 'email@example.com',\n password: 'secret',\n);\nfinal actionLink = res.properties.actionLink;\n```\n", + "response": "```json\nGenerateLinkResponse(\n properties: GenerateLinkProperties(\n actionLink: '',\n emailOtp: '999999',\n hashedToken: '',\n verificationType: GenerateLinkType.signup\n ),\n user: User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'email@example.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'email@example.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n)\n```\n" + } + ] + }, + { + "id": "auth-admin-getuserbyid", + "$ref": "supabase_flutter.getUserById", + "notes": "Get user by id.\n- Fetches the user object from the database based on the user's id.\n- The `getUserById()` method requires the user's id which maps to the `auth.users.id` column.\n", + "params": [ + { + "name": "uid", + "isOptional": false, + "type": "String", + "description": "User ID of the user to fetch." + } + ], + "examples": [ + { + "id": "fetch-the-user-object-using-the-access-token-jwt", + "name": "Fetch the user object using the access_token jwt", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.admin.getUserById(userId);\nfinal user = res.user;\n```\n", + "response": "```json\nUserResponse(\n user: const User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n);\n```\n" + } + ] + }, + { + "id": "auth-admin-inviteuserbyemail", + "$ref": "supabase_flutter.inviteUserByEmail", + "notes": "Sends an invite link to the user's email address.\n", + "params": [ + { + "name": "email", + "isOptional": false, + "type": "String", + "description": "Email address of the user to invite." + }, + { + "name": "redirectTo", + "isOptional": true, + "type": "String", + "description": "URI to redirect the user to after they open the invite link." + }, + { + "name": "data", + "isOptional": true, + "type": "Map", + "description": "A custom data object to store the user's metadata. This maps to the `auth.users.user_metadata` column." + } + ], + "examples": [ + { + "id": "invite-a-user", + "name": "Invite a user", + "isSpotlight": true, + "code": "```dart\nfinal UserResponse res = await supabase.auth.admin\n .inviteUserByEmail('email@example.com');\nfinal User? user = res.user;\n```\n", + "response": "```json\nUserResponse(\n user: User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'email@example.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'email@example.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n);\n```\n" + } + ] + }, + { + "id": "auth-admin-listusers", + "$ref": "supabase_flutter.listUsers", + "notes": "Get a list of users.\n- Defaults to return 50 users per page.\n", + "params": [ + { + "name": "page", + "isOptional": true, + "type": "int", + "description": "What page of users to return." + }, + { + "name": "page", + "isOptional": true, + "type": "int", + "description": "How many users to be returned per page. Defaults to 50." + } + ], + "examples": [ + { + "id": "get-a-full-list-of-users", + "name": "Get a page of users", + "isSpotlight": true, + "code": "```dart\n// Returns the first 50 users.\nfinal List users = await supabase.auth.admin.listUsers();\n```\n", + "response": "```json\n[\n User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n )\n]\n```\n" + }, + { + "id": "get-paginated-list-of-users", + "name": "Paginated list of users", + "isSpotlight": false, + "code": "```dart\n// Returns the 101th - 200th users.\nfinal List res = await supabase.auth.admin.listUsers(\n page: 2,\n perPage: 100,\n);\n```\n" + } + ] + }, + { + "id": "auth-admin-updateuserbyid", + "$ref": "supabase_flutter.updateUserById", + "params": [ + { + "name": "uid", + "isOptional": false, + "type": "GenerateLinkType", + "description": "User ID of the user to update." + }, + { + "name": "attributes", + "isOptional": false, + "type": "AdminUserAttributes", + "description": "Attributes to update for the user.", + "subContent": [ + { + "name": "email", + "isOptional": false, + "type": "String", + "description": "The email address of the user." + }, + { + "name": "password", + "isOptional": false, + "type": "String", + "description": "The password of the user." + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "The phone number of the user." + }, + { + "name": "userMetadata", + "isOptional": true, + "type": "Map", + "description": "A custom data object to store the user's metadata. This maps to the `auth.users.user_metadata` column." + }, + { + "name": "appMetadata", + "isOptional": true, + "type": "Map", + "description": "A custom data object to store the user's application specific metadata. This maps to the `auth.users.app_metadata` column." + }, + { + "name": "emailConfirm", + "isOptional": true, + "type": "bool", + "description": "Whether to confirm the user's email address." + }, + { + "name": "phoneConfirm", + "isOptional": true, + "type": "bool", + "description": "Whether to confirm the user's phone number." + }, + { + "name": "banDuration", + "isOptional": true, + "type": "String", + "description": "Determines how long a user is banned for. Some possible durations include '300ms', '2h45m'." + } + ] + } + ], + "examples": [ + { + "id": "updates-a-users-email", + "name": "Updates a user's email", + "isSpotlight": false, + "code": "```dart\nawait supabase.auth.admin.updateUserById(\n '6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',\n attributes: AdminUserAttributes(\n email: 'new@email.com',\n ),\n);\n```\n" + } + ] + }, + { + "id": "auth-mfa", + "title": "Auth MFA", + "notes": "This section contains methods commonly used for Multi-Factor Authentication (MFA) and are invoked behind the `supabase.auth.mfa` namespace.\n\nCurrently, Supabase supports time-based one-time password (TOTP) and phone verification code as the 2nd factor. Recovery codes are not supported but users can enroll multiple factors, with an upper limit of 10..\n\nHaving a 2nd factor for recovery frees the user of the burden of having to store their recovery codes somewhere. It also reduces the attack surface since multiple recovery codes are usually generated compared to just having 1 backup factor.\n\nLearn more about implementing MFA on your application on our guide [here](https://supabase.com/docs/guides/auth/auth-mfa#overview).\n" + }, + { + "id": "auth-mfa-challenge", + "$ref": "supabase_flutter.challenge", + "notes": "Prepares a challenge used to verify that a user has access to a MFA factor.\n- An [enrolled factor](/docs/reference/dart/auth-mfa-enroll) is required before creating a challenge.\n- To verify a challenge, see [`mfa.verify()`](/docs/reference/dart/auth-mfa-verify).\n", + "params": [ + { + "name": "factorId", + "isOptional": false, + "type": "String", + "description": "System assigned identifier for authenticator device as returned by enroll" + } + ], + "examples": [ + { + "id": "create-mfa-challenge", + "name": "Create a challenge for a factor", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.mfa.challenge(\n factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',\n);\n```\n", + "response": "```json\nAuthMFAChallengeResponse(\n id: '',\n expiresAt: DateTime.fromMillisecondsSinceEpoch(1700000000),\n);\n```\n" + } + ] + }, + { + "id": "auth-mfa-challengeandverify", + "$ref": "supabase_flutter.challengeAndVerify", + "notes": "Helper method which creates a challenge and immediately uses the given code to verify against it thereafter. The verification code is provided by the user by entering a code seen in their authenticator app.\n- An [enrolled factor](/docs/reference/dart/auth-mfa-enroll) is required before invoking `challengeAndVerify()`.\n- Executes [`mfa.challenge()`](/docs/reference/dart/auth-mfa-challenge) and [`mfa.verify()`](/docs/reference/dart/auth-mfa-verify) in a single step.\n", + "params": [ + { + "name": "factorId", + "isOptional": false, + "type": "String", + "description": "System assigned identifier for authenticator device as returned by enroll" + }, + { + "name": "code", + "isOptional": false, + "type": "String", + "description": "The verification code on the user's authenticator app" + } + ], + "examples": [ + { + "id": "challenge-and-verify", + "name": "Create and verify a challenge for a factor", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.mfa.challengeAndVerify(\n factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',\n code: '123456',\n);\n```\n", + "response": "```json\nAuthMFAVerifyResponse(\n accessToken: '',\n tokenType: 'Bearer',\n expiresIn: Duration(seconds: 3600),\n refreshToken: '',\n user: User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n isAnonymous: false,\n factors: [\n Factor(\n id: '',\n friendlyName: 'Important Auth App',\n factorType: FactorType.totp,\n status: 'verified',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z'\n )\n ]\n )\n);\n```\n" + } + ] + }, + { + "id": "auth-mfa-enroll", + "$ref": "supabase_flutter.enroll", + "notes": "Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor. This method creates a new `unverified` factor.\nTo verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app.\nThe user has to enter the code from their authenticator app to verify it.\n- Use `totp` or `phone` as the `factorType` and the returned `id` to create a challenge.\n- To create a challenge, see [`mfa.challenge()`](/docs/reference/dart/auth-mfa-challenge).\n- To verify a challenge, see [`mfa.verify()`](/docs/reference/dart/auth-mfa-verify).\n- To create and verify a challenge in a single step, see [`mfa.challengeAndVerify()`](/docs/reference/dart/auth-mfa-challengeandverify).\n", + "params": [ + { + "name": "factorType", + "isOptional": true, + "type": "String", + "description": "Type of factor being enrolled." + }, + { + "name": "issuer", + "isOptional": true, + "type": "String", + "description": "Domain which the user is enrolled with." + }, + { + "name": "friendlyName", + "isOptional": true, + "type": "String", + "description": "Human readable name assigned to the factor." + }, + { + "name": "phone", + "isOptional": true, + "type": "String", + "description": "Phone number to enroll for phone factor type." + } + ], + "examples": [ + { + "id": "enroll-totp-factor", + "name": "Enroll a time-based, one-time password (TOTP) factor", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.mfa.enroll(factorType: FactorType.totp);\n\nfinal qrCodeUrl = res.totp.qrCode;\n```\n", + "response": "```json\nAuthMFAEnrollResponse(\n id: '',\n type: FactorType.totp,\n totp: TOTPEnrollment(\n qrCode: '',\n secret: '',\n uri: '',\n ),\n phone: null,\n);\n```\n" + }, + { + "id": "enroll-phone-factor", + "name": "Enroll a Phone Factor", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.mfa.enroll(factorType: FactorType.phone, phone: '+1234567890');\n\nfinal phone = res.phone;\n```\n", + "response": "```json\nAuthMFAEnrollResponse(\n id: '',\n type: FactorType.phone,\n totp: null,\n phone: PhoneEnrollment(\n phone: '+1234567890',\n ),\n);\n```\n" + } + ] + }, + { + "id": "auth-mfa-getauthenticatorassurancelevel", + "$ref": "supabase_flutter.getAuthenticatorAssuranceLevel", + "notes": "Returns the Authenticator Assurance Level (AAL) for the active session.\n- Authenticator Assurance Level (AAL) is the measure of the strength of an authentication mechanism.\n- In Supabase, having an AAL of `aal1` means the user has signed in with their first factor, such as email, password, or OAuth sign-in. An AAL of `aal2` means the user has also signed in with their second factor, such as a time-based, one-time-password (TOTP).\n- If the user has a verified factor, the `nextLevel` field returns `aal2`. Otherwise, it returns `aal1`.\n", + "examples": [ + { + "id": "get-aal", + "name": "Get the AAL details of a session", + "isSpotlight": true, + "code": "```dart\nfinal res = supabase.auth.mfa.getAuthenticatorAssuranceLevel();\nfinal currentLevel = res.currentLevel;\nfinal nextLevel = res.nextLevel;\nfinal currentAuthenticationMethods = res.currentAuthenticationMethods;\n```\n", + "response": "```json\nAuthMFAGetAuthenticatorAssuranceLevelResponse(\n currentLevel: AuthenticatorAssuranceLevels.aal1,\n nextLevel: AuthenticatorAssuranceLevels.aal2,\n currentAuthenticationMethods: [\n AMREntry(\n method: AMRMethod.password,\n timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000),\n )\n ]\n}\n```\n" + } + ] + }, + { + "id": "auth-mfa-unenroll", + "$ref": "supabase_flutter.unenroll", + "notes": "Unenroll removes a MFA factor.\nA user has to have an `aal2` authenticator level in order to unenroll a `verified` factor.\n", + "params": [ + { + "name": "factorId", + "isOptional": false, + "type": "String", + "description": "System assigned identifier for authenticator device as returned by enroll" + } + ], + "examples": [ + { + "id": "unenroll-a-factor", + "name": "Unenroll a factor", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.mfa.unenroll(\n '34e770dd-9ff9-416c-87fa-43b31d7ef225',\n);\n```\n", + "response": "```json\nAuthMFAUnenrollResponse(\n id: '',\n);\n```\n" + } + ] + }, + { + "id": "auth-mfa-verify", + "$ref": "supabase_flutter.verify", + "notes": "Verifies a code against a challenge. The verification code is provided by the user by entering a code seen in their authenticator app.\n- To verify a challenge, please [create a challenge](/docs/reference/dart/auth-mfa-challenge) first.\n", + "params": [ + { + "name": "factorId", + "isOptional": false, + "type": "String", + "description": "System assigned identifier for authenticator device as returned by enroll" + }, + { + "name": "challengeId", + "isOptional": false, + "type": "String", + "description": "The ID of the challenge to verify" + }, + { + "name": "code", + "isOptional": false, + "type": "String", + "description": "The verification code on the user's authenticator app" + } + ], + "examples": [ + { + "id": "verify-challenge", + "name": "Verify a challenge for a factor", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.auth.mfa.verify(\n factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',\n challengeId: '4034ae6f-a8ce-4fb5-8ee5-69a5863a7c15',\n code: '123456',\n);\n```\n", + "response": "```json\nAuthMFAVerifyResponse(\n accessToken: '',\n tokenType: 'Bearer',\n expiresIn: Duration(seconds: 3600),\n refreshToken: '',\n user: User(\n id: '11111111-1111-1111-1111-111111111111',\n aud: 'authenticated',\n role: 'authenticated',\n email: 'example@email.com',\n emailConfirmedAt: '2024-01-01T00:00:00Z',\n phone: '',\n lastSignInAt: '2024-01-01T00:00:00Z',\n appMetadata: {\n 'provider': 'email',\n 'providers': ['email']\n },\n userMetadata: {},\n identities: [\n UserIdentity(\n identityId: '22222222-2222-2222-2222-222222222222',\n id: '11111111-1111-1111-1111-111111111111',\n userId: '11111111-1111-1111-1111-111111111111',\n identityData: {\n 'email': 'example@email.com',\n 'email_verified': false,\n 'phone_verified': false,\n 'sub': '11111111-1111-1111-1111-111111111111'\n },\n provider: 'email',\n lastSignInAt: '2024-01-01T00:00:00Z',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n ),\n ],\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z',\n isAnonymous: false,\n factors: [\n Factor(\n id: '',\n friendlyName: 'Important Auth App',\n factorType: FactorType.totp,\n status: 'verified',\n createdAt: '2024-01-01T00:00:00Z',\n updatedAt: '2024-01-01T00:00:00Z'\n )\n ]\n )\n);\n```\n" + } + ] + }, + { + "id": "auth-passkey", + "title": "Auth Passkey", + "notes": "This section contains methods for WebAuthn passkey registration, authentication, and management. Methods are invoked behind the `supabase.auth.passkey` namespace.\n\nThese methods expose the server side of the WebAuthn ceremony. The client side (the FaceID/TouchID/security key prompt) has to be performed with a platform passkey API: `navigator.credentials.create()`/`get()` on web, or a passkey plugin on iOS/Android/macOS. Options and credentials are exchanged as `Map` in the W3C WebAuthn Level 3 JSON format.\n\nFor a one-call alternative that runs the full ceremony, see [`signInWithPasskey()`](/docs/reference/dart/auth-signinwithpasskey) and [`registerPasskey()`](/docs/reference/dart/auth-registerpasskey) on `supabase_flutter`.\n\nPasskey support is a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys.\n" + }, + { + "id": "auth-passkey-delete", + "$ref": "supabase_flutter.delete", + "notes": "Deletes a passkey from the signed in user.\n- If the user has verified MFA factors, the session has to be at `aal2` to manage passkeys.\n", + "params": [ + { + "name": "passkeyId", + "isOptional": false, + "type": "String", + "description": "ID of the passkey to delete." + } + ], + "examples": [ + { + "id": "delete-passkey", + "name": "Delete a passkey", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.passkey.delete(\n passkeyId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',\n);\n```\n" + } + ] + }, + { + "id": "auth-passkey-list", + "$ref": "supabase_flutter.list", + "notes": "Returns the list of passkeys registered to the signed in user.\n", + "examples": [ + { + "id": "list-passkeys", + "name": "List the current user's passkeys", + "isSpotlight": true, + "code": "```dart\nfinal List passkeys = await supabase.auth.passkey.list();\n```\n" + } + ] + }, + { + "id": "auth-passkey-startauthentication", + "$ref": "supabase_flutter.startAuthentication", + "notes": "Starts a passkey sign in.\n- Does not require an existing session.\n- Pass the returned `options` to the platform's passkey API to obtain an assertion, then call [`passkey.verifyAuthentication()`](/docs/reference/dart/auth-passkey-verifyauthentication) with the result.\n", + "params": [ + { + "name": "captchaToken", + "isOptional": true, + "type": "String", + "description": "Captcha token to be used for captcha verification." + } + ], + "examples": [ + { + "id": "start-passkey-authentication", + "name": "Start a passkey sign in", + "isSpotlight": true, + "code": "```dart\nfinal PasskeyAuthenticationOptionsResponse authentication =\n await supabase.auth.passkey.startAuthentication();\n\n// Hand authentication.options to the platform passkey API.\n```\n" + } + ] + }, + { + "id": "auth-passkey-startregistration", + "$ref": "supabase_flutter.startRegistration", + "notes": "Starts the registration of a new passkey for the signed in user.\n- Requires a signed in (non-anonymous) user.\n- Pass the returned `options` to the platform's passkey API to create the credential, then call [`passkey.verifyRegistration()`](/docs/reference/dart/auth-passkey-verifyregistration) with the result.\n", + "examples": [ + { + "id": "start-passkey-registration", + "name": "Start a passkey registration", + "isSpotlight": true, + "code": "```dart\nfinal PasskeyRegistrationOptionsResponse registration =\n await supabase.auth.passkey.startRegistration();\n\n// Hand registration.options to the platform passkey API.\n```\n" + } + ] + }, + { + "id": "auth-passkey-update", + "$ref": "supabase_flutter.update", + "notes": "Updates the friendly name of a passkey.\n", + "params": [ + { + "name": "passkeyId", + "isOptional": false, + "type": "String", + "description": "ID of the passkey to rename." + }, + { + "name": "friendlyName", + "isOptional": false, + "type": "String", + "description": "New human readable name for the passkey. Limited to 120 characters." + } + ], + "examples": [ + { + "id": "rename-passkey", + "name": "Rename a passkey", + "isSpotlight": true, + "code": "```dart\nfinal Passkey passkey = await supabase.auth.passkey.update(\n passkeyId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',\n friendlyName: 'Work laptop',\n);\n```\n" + } + ] + }, + { + "id": "auth-passkey-verifyauthentication", + "$ref": "supabase_flutter.verifyAuthentication", + "notes": "Completes a passkey sign in and returns the new session.\n- On success the session is persisted and an `AuthChangeEvent.signedIn` event is fired.\n", + "params": [ + { + "name": "challengeId", + "isOptional": false, + "type": "String", + "description": "The challenge ID returned by `passkey.startAuthentication()`." + }, + { + "name": "credential", + "isOptional": false, + "type": "Map", + "description": "The assertion produced by the platform's passkey API, serialized in the W3C `AuthenticationResponseJSON` format." + } + ], + "examples": [ + { + "id": "verify-passkey-authentication", + "name": "Verify a passkey sign in", + "isSpotlight": true, + "code": "```dart\nfinal AuthResponse res = await supabase.auth.passkey.verifyAuthentication(\n challengeId: authentication.challengeId,\n credential: credential,\n);\nfinal Session? session = res.session;\nfinal User? user = res.user;\n```\n" + } + ] + }, + { + "id": "auth-passkey-verifyregistration", + "$ref": "supabase_flutter.verifyRegistration", + "notes": "Completes the registration of a new passkey and returns the stored [`Passkey`](/docs/reference/dart/auth-passkey-api).\n", + "params": [ + { + "name": "challengeId", + "isOptional": false, + "type": "String", + "description": "The challenge ID returned by `passkey.startRegistration()`." + }, + { + "name": "credential", + "isOptional": false, + "type": "Map", + "description": "The credential created by the platform's passkey API, serialized in the W3C `RegistrationResponseJSON` format." + } + ], + "examples": [ + { + "id": "verify-passkey-registration", + "name": "Verify a passkey registration", + "isSpotlight": true, + "code": "```dart\nfinal Passkey passkey = await supabase.auth.passkey.verifyRegistration(\n challengeId: registration.challengeId,\n credential: credential,\n);\n```\n" + } + ] + }, + { + "id": "passkey-admin", + "title": "Passkey Admin", + "notes": "Contains passkey administration methods, accessed under the `supabase.auth.admin.passkey` namespace. Requires a `secret` key.\n\nPasskey support is a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys.\n" + }, + { + "id": "passkey-admin-deletepasskey", + "$ref": "supabase_flutter.deletePasskey", + "notes": "Deletes a passkey from a user.\n", + "params": [ + { + "name": "userId", + "isOptional": false, + "type": "String", + "description": "User ID that owns the passkey." + }, + { + "name": "passkeyId", + "isOptional": false, + "type": "String", + "description": "ID of the passkey to delete." + } + ], + "examples": [ + { + "id": "admin-delete-passkey", + "name": "Delete a user's passkey", + "isSpotlight": true, + "code": "```dart\nawait supabase.auth.admin.passkey.deletePasskey(\n userId: '11111111-1111-1111-1111-111111111111',\n passkeyId: '34e770dd-9ff9-416c-87fa-43b31d7ef225',\n);\n```\n" + } + ] + }, + { + "id": "passkey-admin-listpasskeys", + "$ref": "supabase_flutter.listPasskeys", + "notes": "Returns the list of passkeys registered to the user with the given ID.\n", + "params": [ + { + "name": "userId", + "isOptional": false, + "type": "String", + "description": "User ID whose passkeys should be returned." + } + ], + "examples": [ + { + "id": "admin-list-passkeys", + "name": "List a user's passkeys", + "isSpotlight": true, + "code": "```dart\nfinal List passkeys = await supabase.auth.admin.passkey.listPasskeys(\n userId: '11111111-1111-1111-1111-111111111111',\n);\n```\n" + } + ] + }, + { + "id": "functions-invoke", + "$ref": "supabase_flutter.invoke", + "description": "Invokes a Supabase Function. See the [guide](/docs/guides/functions) for details on writing Functions.\n", + "notes": "- Requires an Authorization header.\n- Invoke params generally match the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) spec.\n", + "params": [ + { + "name": "functionName", + "isOptional": false, + "type": "String", + "description": "The name of the function to invoke." + }, + { + "name": "headers", + "isOptional": true, + "type": "Map", + "description": "Custom headers to send with the request." + }, + { + "name": "body", + "isOptional": true, + "type": "Map", + "description": "The body of the request." + }, + { + "name": "method", + "isOptional": true, + "type": "HttpMethod", + "description": "HTTP method of the request. Defaults to POST." + } + ], + "examples": [ + { + "id": "basic-invocation", + "name": "Basic invocation.", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.functions.invoke('hello', body: {'foo': 'baa'});\nfinal data = res.data;\n```\n" + }, + { + "id": "parsing-custom-headers", + "name": "Parsing custom headers.", + "description": "Any `headers` will be passed through to the function. A common pattern is to pass a logged-in user's JWT token as an Authorization header.\n", + "isSpotlight": true, + "code": "```dart\nfinal res = await supabase.functions.invoke(\n 'hello',\n body: {'foo': 'baa'},\n headers: {\n 'Authorization': 'Bearer ${supabase.auth.currentSession?.accessToken}'\n },\n);\n```\n" + } + ] + }, + { + "id": "getchannels", + "$ref": "supabase_flutter.getChannels", + "description": "Returns all Realtime channels.\n", + "examples": [ + { + "id": "get-all-channels", + "name": "Get all channels", + "isSpotlight": true, + "code": "```dart\nfinal channels = supabase.getChannels();\n```\n" + } + ] + }, + { + "id": "removeallchannels", + "$ref": "supabase_flutter.removeAllChannels", + "description": "Unsubscribes and removes all Realtime channels from Realtime client.\n", + "notes": "- Removing channels is a great way to maintain the performance of your project's Realtime service as well as your database if you're listening to Postgres changes. Supabase will automatically handle cleanup 30 seconds after a client is disconnected, but unused channels may cause degradation as more clients are simultaneously subscribed.\n", + "examples": [ + { + "id": "remove-all-channels", + "name": "Remove all channels", + "isSpotlight": true, + "code": "```dart\nfinal statuses = await supabase.removeAllChannels();\n```\n" + } + ] + }, + { + "id": "removechannel", + "$ref": "supabase_flutter.removeChannel", + "description": "Unsubscribes and removes Realtime channel from Realtime client.\n", + "notes": "- Removing a channel is a great way to maintain the performance of your project's Realtime service as well as your database if you're listening to Postgres changes. Supabase will automatically handle cleanup 30 seconds after a client is disconnected, but unused channels may cause degradation as more clients are simultaneously subscribed.\n", + "examples": [ + { + "id": "removes-a-channel", + "name": "Remove a channel", + "isSpotlight": true, + "code": "```dart\nfinal status = await supabase.removeChannel(channel);\n```\n" + } + ] + }, + { + "id": "stream", + "$ref": "supabase_flutter.stream", + "description": "Returns real-time data from your table as a `Stream`.\n", + "notes": "- Realtime is disabled by default for new tables. You can turn it on by [managing replication](/docs/guides/realtime/postgres-changes#replication-setup).\n- `stream()` will emit the initial data as well as any further change on the database as `Stream>>` by combining Postgrest and Realtime.\n- Takes a list of primary key column names that will be used to update and delete the proper records within the SDK.\n- To use a private Realtime channel, pass `channelOptions: const RealtimeChannelConfig(private: true)` to the `stream()` call.\n- The following filters are available\n - `.eq('column', value)` listens to rows where the column equals the value\n - `.neq('column', value)` listens to rows where the column does not equal the value\n - `.gt('column', value)` listens to rows where the column is greater than the value\n - `.gte('column', value)` listens to rows where the column is greater than or equal to the value\n - `.lt('column', value)` listens to rows where the column is less than the value\n - `.lte('column', value)` listens to rows where the column is less than or equal to the value\n - `.inFilter('column', [val1, val2, val3])` listens to rows where the column is one of the values\n", + "examples": [ + { + "id": "listen-to-table", + "name": "Listen to a table", + "isSpotlight": true, + "code": "```dart\nsupabase.from('countries')\n .stream(primaryKey: ['id'])\n .listen((List> data) {\n // Do something awesome with the data\n});\n```\n" + }, + { + "id": "with-filter-order-limit", + "name": "With filter, order and limit", + "code": "```dart\nsupabase.from('countries')\n .stream(primaryKey: ['id'])\n .eq('id', 120)\n .order('name')\n .limit(10);\n```\n" + }, + { + "id": "with-in-filter", + "name": "With an IN filter", + "code": "```dart\nsupabase.from('countries')\n .stream(primaryKey: ['id'])\n .inFilter('id', [1, 2, 3])\n .order('name')\n .limit(10);\n```\n" + }, + { + "id": "using-stream-with-stream-builder", + "name": "Using `stream()` with `StreamBuilder`", + "description": "When using `stream()` with a `StreamBuilder` within your Flutter application, make sure to store your stream in a variable to prevent refetching upon rebuilding.\n", + "code": "```dart\nfinal supabase = Supabase.instance.client;\n\nclass MyWidget extends StatefulWidget {\n const MyWidget({Key? key}) : super(key: key);\n\n @override\n State createState() => _MyWidgetState();\n}\n\nclass _MyWidgetState extends State {\n // Persist the stream in a local variable to prevent refetching upon rebuilds\n final _stream = supabase.from('countries').stream(primaryKey: ['id']);\n\n @override\n Widget build(BuildContext context) {\n return StreamBuilder(\n stream: _stream,\n builder: (context, snapshot) {\n // Return your widget with the data from the snapshot\n },\n );\n }\n}\n```\n" + } + ] + }, + { + "id": "subscribe", + "$ref": "supabase_flutter.subscribe", + "description": "Subscribe to realtime changes in your database.\n", + "notes": "- Realtime is disabled by default for new tables. You can turn it on by [managing replication](/docs/guides/realtime/postgres-changes#replication-setup).\n- If you want to receive the \"previous\" data for updates and deletes, you will need to set `REPLICA IDENTITY` to `FULL`, like this: `ALTER TABLE your_table REPLICA IDENTITY FULL;`\n", + "examples": [ + { + "id": "listen-to-database-changes", + "name": "Listen to database changes", + "isSpotlight": true, + "code": "```dart\nsupabase\n .channel('public:countries')\n .onPostgresChanges(\n event: PostgresChangeEvent.all,\n schema: 'public',\n table: 'countries',\n callback: (payload) {\n print('Change received: ${payload.toString()}');\n })\n .subscribe();\n```\n" + }, + { + "id": "listen-to-inserts", + "name": "Listen to inserts", + "code": "```dart\nsupabase\n .channel('public:countries')\n .onPostgresChanges(\n event: PostgresChangeEvent.insert,\n schema: 'public',\n table: 'countries',\n callback: (payload) {\n print('Change received: ${payload.toString()}');\n })\n .subscribe();\n```\n" + }, + { + "id": "listen-to-updates", + "name": "Listen to updates", + "description": "By default, Supabase will send only the updated record. If you want to receive the previous values as well you can\nenable full replication for the table you are listening too:\n\n```sql\nalter table \"your_table\" replica identity full;\n```\n", + "code": "```dart\nsupabase\n .channel('public:countries')\n .onPostgresChanges(\n event: PostgresChangeEvent.update,\n schema: 'public',\n table: 'countries',\n callback: (payload) {\n print('Change received: ${payload.toString()}');\n })\n .subscribe();\n```\n" + }, + { + "id": "listen-to-deletes", + "name": "Listen to deletes", + "description": "By default, Supabase does not send deleted records. If you want to receive the deleted record you can\nenable full replication for the table you are listening too:\n\n```sql\nalter table \"your_table\" replica identity full;\n```\n", + "code": "```dart\nsupabase\n .channel('public:countries')\n .onPostgresChanges(\n event: PostgresChangeEvent.delete,\n schema: 'public',\n table: 'countries',\n callback: (payload) {\n print('Change received: ${payload.toString()}');\n })\n .subscribe();\n```\n" + }, + { + "id": "listen-to-multiple-events", + "name": "Listen to multiple events", + "description": "You can chain listeners if you want to listen to multiple events for each table.", + "code": "```dart\nsupabase\n .channel('public:countries')\n .onPostgresChanges(\n event: PostgresChangeEvent.insert,\n schema: 'public',\n table: 'countries',\n callback: (payload) {\n print('Insert event received: ${payload.toString()}');\n })\n .onPostgresChanges(\n event: PostgresChangeEvent.delete,\n schema: 'public',\n table: 'countries',\n callback: (payload) {\n print('Delete event received: ${payload.toString()}');\n })\n .subscribe();\n```\n" + }, + { + "id": "listening-to-row-level-changes", + "name": "Listen to row level changes", + "description": "You can listen to individual rows by using the `filter` parameter passing a `PostgresChangeFilter` object with `PostgresChangeFilterType.eq` as `type`.", + "code": "```dart\nsupabase\n .channel('public:countries:id=eq.200')\n .onPostgresChanges(\n event: PostgresChangeEvent.delete,\n schema: 'public',\n table: 'countries',\n filter: PostgresChangeFilter(\n type: PostgresChangeFilterType.eq,\n column: 'id',\n value: 200,\n ),\n callback: (payload) {\n print('Change received: ${payload.toString()}');\n })\n .subscribe();\n```\n" + }, + { + "id": "listen-to-broadcast", + "name": "Listen to broadcast messages", + "code": "```dart\nsupabase\n .channel('room1')\n .onBroadcast(\n event: 'cursor-pos',\n callback: (payload) {\n print('Cursor position received!: $payload');\n })\n .subscribe();\n```\n" + }, + { + "id": "listen-to-presence-events", + "name": "Listen to presence events", + "code": "```dart\nfinal channel = supabase.channel('room1');\n\nchannel.onPresenceSync((payload) {\n print('Synced presence state: ${channel.presenceState()}');\n}).onPresenceJoin((payload) {\n print('Newly joined presences $payload');\n}).onPresenceLeave((payload) {\n print('Newly left presences: $payload');\n}).subscribe((status, error) async {\n if (status == RealtimeSubscribeStatus.subscribed) {\n await channel.track({'online_at': DateTime.now().toIso8601String()});\n }\n});\n```\n" + } + ] + }, + { + "id": "file-buckets", + "title": "File Buckets", + "notes": "This section contains methods for working with File Buckets.\n" + }, + { + "id": "file-buckets-createbucket", + "$ref": "supabase_flutter.createBucket", + "description": "Creates a new Storage bucket\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: `insert`\n - `objects` permissions: none\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "id", + "isOptional": false, + "type": "String", + "description": "A unique identifier for the bucket you are creating." + }, + { + "name": "bucketOptions", + "isOptional": true, + "type": "BucketOptions", + "description": "A parameter to optionally make the bucket public.", + "subContent": [ + { + "name": "public", + "isOptional": false, + "type": "bool", + "description": "The visibility of the bucket. Public buckets don't require an authorization token to download objects, but still require a valid token for all other operations. By default, buckets are private." + }, + { + "name": "fileSizeLimit", + "isOptional": true, + "type": "String", + "description": "Specifies the max file size in bytes that can be uploaded to this bucket. The global file size limit takes precedence over this value. The default value is null, which doesn't set a per bucket file size limit." + }, + { + "name": "allowedMimeTypes", + "isOptional": true, + "type": "List", + "description": "Specifies the allowed mime types that this bucket can accept during upload. The default value is null, which allows files with all mime types to be uploaded. Each mime type specified can be a wildcard, e.g. image/*, or a specific mime type, e.g. image/png." + } + ] + } + ], + "examples": [ + { + "id": "create-bucket", + "name": "Create bucket", + "isSpotlight": true, + "code": "```dart\nfinal String bucketId = await supabase\n .storage\n .createBucket('avatars');\n```\n", + "response": "```json\n'avatars'\n```\n" + } + ] + }, + { + "id": "file-buckets-createsignedurl", + "$ref": "supabase_flutter.createSignedUrl", + "description": "Create signed url to download file without requiring permissions. This URL can be valid for a set number of seconds.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: `select`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "path", + "isOptional": false, + "type": "String", + "description": "The file path, including the file name. For example folder/image.png." + }, + { + "name": "expiresIn", + "isOptional": false, + "type": "int", + "description": "The number of seconds until the signed URL expires. For example, 60 for a URL which is valid for one minute." + }, + { + "name": "transform", + "isOptional": true, + "type": "TransformOptions", + "description": "Transform the asset before serving it to the client.", + "subContent": [ + { + "name": "width", + "isOptional": true, + "type": "int", + "description": "The width of the image in pixels." + }, + { + "name": "height", + "isOptional": true, + "type": "int", + "description": "The height of the image in pixels." + }, + { + "name": "resize", + "isOptional": true, + "type": "ResizeMode", + "description": "Specifies how image cropping should be handled when performing image transformations. Defaults to `ResizeMode.cover`." + }, + { + "name": "quality", + "isOptional": true, + "type": "int", + "description": "Set the quality of the returned image. A number from 20 to 100, with 100 being the highest quality. Defaults to 80" + }, + { + "name": "format", + "isOptional": true, + "type": "RequestImageFormat", + "description": "Specify the format of the image requested. When using 'origin' we force the format to be the same as the original image. When this option is not passed in, images are optimized to modern image formats like Webp." + } + ] + } + ], + "examples": [ + { + "id": "create-signed-url", + "name": "Create Signed URL", + "isSpotlight": true, + "code": "```dart\nfinal String signedUrl = await supabase\n .storage\n .from('avatars')\n .createSignedUrl('avatar1.png', 60);\n```\n" + }, + { + "id": "create-signed-url-with-transform", + "name": "With transform", + "code": "```dart\nfinal String signedUrl = await supabase\n .storage\n .from('avatars')\n .createSignedUrl(\n 'avatar1.png',\n 60,\n transform: TransformOptions(\n width: 200,\n height: 200,\n ),\n );\n```\n", + "response": "```json\n'https://example.supabase.co/storage/v1/object/sign/avatars/folder/avatar1.png?token=''\n```\n" + } + ] + }, + { + "id": "file-buckets-deletebucket", + "$ref": "supabase_flutter.deleteBucket", + "description": "Deletes an existing bucket. A bucket can't be deleted with existing objects inside it. You must first `empty()` the bucket.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: `select` and `delete`\n - `objects` permissions: none\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "id", + "isOptional": false, + "type": "String", + "description": "A unique identifier for the bucket you are deleting." + } + ], + "examples": [ + { + "id": "delete-bucket", + "name": "Delete bucket", + "isSpotlight": true, + "code": "```dart\nfinal String res = await supabase\n .storage\n .deleteBucket('avatars');\n```\n", + "response": "```json\n'Successfully deleted'\n```\n" + } + ] + }, + { + "id": "file-buckets-download", + "$ref": "supabase_flutter.download", + "description": "Downloads a file.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: `select`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "path", + "isOptional": false, + "type": "String", + "description": "The full path and file name of the file to be downloaded. For example folder/image.png." + }, + { + "name": "transform", + "isOptional": true, + "type": "TransformOptions", + "description": "Transform the asset before serving it to the client.", + "subContent": [ + { + "name": "width", + "isOptional": true, + "type": "int", + "description": "The width of the image in pixels." + }, + { + "name": "height", + "isOptional": true, + "type": "int", + "description": "The height of the image in pixels." + }, + { + "name": "resize", + "isOptional": true, + "type": "ResizeMode", + "description": "Specifies how image cropping should be handled when performing image transformations. Defaults to `ResizeMode.cover`." + }, + { + "name": "quality", + "isOptional": true, + "type": "int", + "description": "Set the quality of the returned image. A number from 20 to 100, with 100 being the highest quality. Defaults to 80" + }, + { + "name": "format", + "isOptional": true, + "type": "RequestImageFormat", + "description": "Specify the format of the image requested. When using 'origin' we force the format to be the same as the original image. When this option is not passed in, images are optimized to modern image formats like Webp." + } + ] + } + ], + "examples": [ + { + "id": "download-file", + "name": "Download file", + "isSpotlight": true, + "code": "```dart\nfinal Uint8List file = await supabase\n .storage\n .from('avatars')\n .download('avatar1.png');\n```\n", + "response": "```json\n\n```\n" + }, + { + "id": "download-file-with-transform", + "name": "With transform", + "isSpotlight": true, + "code": "```dart\nfinal Uint8List file = await supabase\n .storage\n .from('avatars')\n .download(\n 'avatar1.png',\n transform: TransformOptions(\n width: 200,\n height: 200,\n ),\n );\n```\n", + "response": "```json\n\n```\n" + } + ] + }, + { + "id": "file-buckets-emptybucket", + "$ref": "supabase_flutter.emptyBucket", + "description": "Removes all objects inside a single bucket.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: `select`\n - `objects` permissions: `select` and `delete`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "id", + "isOptional": false, + "type": "String", + "description": "A unique identifier for the bucket you are emptying." + } + ], + "examples": [ + { + "id": "empty-bucket", + "name": "Empty bucket", + "isSpotlight": true, + "code": "```dart\nfinal String res = await supabase\n .storage\n .emptyBucket('avatars');\n```\n", + "response": "```json\n'Successfully emptied'\n```\n" + } + ] + }, + { + "id": "file-buckets-getbucket", + "$ref": "supabase_flutter.getBucket", + "description": "Retrieves the details of an existing Storage bucket.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: `select`\n - `objects` permissions: none\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "id", + "isOptional": false, + "type": "String", + "description": "The unique identifier of the bucket you would like to retrieve." + } + ], + "examples": [ + { + "id": "get-bucket", + "name": "Get bucket", + "isSpotlight": true, + "code": "```dart\nfinal Bucket bucket = await supabase\n .storage\n .getBucket('avatars');\n```\n", + "response": "```json\nBucket(\n id: 'avatars',\n name: 'avatars',\n owner: '',\n public: false,\n file_size_limit: 1024,\n allowed_mime_types: [\n 'image/png'\n ],\n created_at: '2024-05-22T22:26:05.100Z',\n updated_at: '2024-05-22T22:26:05.100Z'\n)\n```\n" + } + ] + }, + { + "id": "file-buckets-getpublicurl", + "$ref": "supabase_flutter.getPublicUrl", + "description": "Retrieve URLs for assets in public buckets\n", + "notes": "- The bucket needs to be set to public, either via [updateBucket()](/docs/reference/dart/storage-updatebucket) or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing \"Make public\"\n- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: none\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "path", + "isOptional": false, + "type": "String", + "description": "The path and name of the file to generate the public URL for. For example folder/image.png." + }, + { + "name": "transform", + "isOptional": true, + "type": "TransformOptions", + "description": "Transform the asset before serving it to the client.", + "subContent": [ + { + "name": "width", + "isOptional": true, + "type": "int", + "description": "The width of the image in pixels." + }, + { + "name": "height", + "isOptional": true, + "type": "int", + "description": "The height of the image in pixels." + }, + { + "name": "resize", + "isOptional": true, + "type": "ResizeMode", + "description": "Specifies how image cropping should be handled when performing image transformations. Defaults to `ResizeMode.cover`." + }, + { + "name": "quality", + "isOptional": true, + "type": "int", + "description": "Set the quality of the returned image. A number from 20 to 100, with 100 being the highest quality. Defaults to 80" + }, + { + "name": "format", + "isOptional": true, + "type": "RequestImageFormat", + "description": "Specify the format of the image requested. When using 'origin' we force the format to be the same as the original image. When this option is not passed in, images are optimized to modern image formats like Webp." + } + ] + } + ], + "examples": [ + { + "id": "returns-the-url-for-an-asset-in-a-public-bucket", + "name": "Returns the URL for an asset in a public bucket", + "isSpotlight": true, + "code": "```dart\nfinal String publicUrl = supabase\n .storage\n .from('public-bucket')\n .getPublicUrl('avatar1.png');\n```\n", + "response": "```json\n'https://example.supabase.co/storage/v1/object/public/public-bucket/folder/avatar1.png'\n```\n" + }, + { + "id": "returns-the-url-for-an-asset-in-a-public-bucket-with-transform", + "name": "With transform", + "isSpotlight": true, + "code": "```dart\nfinal String publicUrl = await supabase\n .storage\n .from('public-bucket')\n .getPublicUrl(\n 'avatar1.png',\n transform: TransformOptions(\n width: 200,\n height: 200,\n ),\n );\n```\n", + "response": "```json\n'https://example.supabase.co/storage/v1/object/public/public-bucket/folder/avatar1.png'\n```\n" + } + ] + }, + { + "id": "file-buckets-list", + "$ref": "supabase_flutter.list", + "description": "Lists all the files within a bucket.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: `select`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "path", + "isOptional": false, + "type": "String", + "description": "The folder path." + }, + { + "name": "searchOptions", + "isOptional": true, + "type": "SearchOptions", + "description": "Options for the search operations such as limit and offset.", + "subContent": [ + { + "name": "limit", + "isOptional": true, + "type": "int", + "description": "The number of files you want to be returned." + }, + { + "name": "offset", + "isOptional": true, + "type": "int", + "description": "The starting position." + }, + { + "name": "sortBy", + "isOptional": true, + "type": "SortBy", + "description": "The column to sort by. Can be any column inside a FileObject.", + "subContent": [ + { + "name": "column", + "isOptional": true, + "type": "String" + }, + { + "name": "order", + "isOptional": true, + "type": "String" + } + ] + }, + { + "name": "search", + "isOptional": true, + "type": "String", + "description": "The search string to filter files by." + } + ] + } + ], + "examples": [ + { + "id": "list-files-in-a-bucket", + "name": "List files in a bucket", + "isSpotlight": true, + "code": "```dart\nfinal List objects = await supabase\n .storage\n .from('avatars')\n .list();\n```\n", + "response": "```json\n[\n FileObject(\n name: 'avatar1.png',\n id: 'e668cf7f-821b-4a2f-9dce-7dfa5dd1cfd2',\n updated_at: '2024-05-22T23:06:05.580Z',\n created_at: '2024-05-22T23:04:34.443Z',\n last_accessed_at: '2024-05-22T23:04:34.443Z',\n metadata: {\n eTag: 'c5e8c553235d9af30ef4f6e280790b92',\n size: 32175,\n mimetype: 'image/png',\n cacheControl: 'max-age=3600',\n lastModified: '2024-05-22T23:06:05.574Z',\n contentLength: 32175,\n httpStatusCode: 200\n ),\n]\n```\n" + } + ] + }, + { + "id": "file-buckets-listbuckets", + "$ref": "supabase_flutter.listBuckets", + "description": "Retrieves the details of all Storage buckets within an existing product.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: `select`\n - `objects` permissions: none\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "examples": [ + { + "id": "list-buckets", + "name": "List buckets", + "isSpotlight": true, + "code": "```dart\nfinal List buckets = await supabase\n .storage\n .listBuckets();\n```\n", + "response": "```json\n[\n Bucket(\n id: 'avatars',\n name: 'avatars',\n owner: '',\n public: false,\n file_size_limit: 1024,\n allowed_mime_types: [\n 'image/png'\n ],\n created_at: '2024-05-22T22:26:05.100Z',\n updated_at: '2024-05-22T22:26:05.100Z'\n ),\n]\n```\n" + } + ] + }, + { + "id": "file-buckets-move", + "$ref": "supabase_flutter.move", + "description": "Moves an existing file, optionally renaming it at the same time.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: `update` and `select`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "fromPath", + "isOptional": false, + "type": "String", + "description": "The original file path, including the current file name. For example folder/image.png." + }, + { + "name": "toPath", + "isOptional": false, + "type": "String", + "description": "The new file path, including the new file name. For example folder/image-new.png." + } + ], + "examples": [ + { + "id": "move-file", + "name": "Move file", + "isSpotlight": true, + "code": "```dart\nfinal String result = await supabase\n .storage\n .from('avatars')\n .move('public/avatar1.png', 'private/avatar2.png');\n```\n", + "response": "```json\n'Successfully moved'\n```\n" + } + ] + }, + { + "id": "file-buckets-remove", + "$ref": "supabase_flutter.remove", + "description": "Deletes files within the same bucket\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: `delete` and `select`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "paths", + "isOptional": false, + "type": "List", + "description": "A list of files to delete, including the path and file name. For example ['folder/image.png']." + } + ], + "examples": [ + { + "id": "delete-file", + "name": "Delete file", + "isSpotlight": true, + "code": "```dart\nfinal List objects = await supabase\n .storage\n .from('avatars')\n .remove(['avatar1.png']);\n```\n", + "response": "```json\n[\n FileObject(\n name: 'avatar1.png',\n id: 'e668cf7f-821b-4a2f-9dce-7dfa5dd1cfd2',\n updated_at: '2024-05-22T23:06:05.580Z',\n created_at: '2024-05-22T23:04:34.443Z',\n last_accessed_at: '2024-05-22T23:04:34.443Z',\n metadata: {\n eTag: 'c5e8c553235d9af30ef4f6e280790b92',\n size: 32175,\n mimetype: 'image/png',\n cacheControl: 'max-age=3600',\n lastModified: '2024-05-22T23:06:05.574Z',\n contentLength: 32175,\n httpStatusCode: 200\n ),\n]\n```\n" + } + ] + }, + { + "id": "file-buckets-update", + "$ref": "supabase_flutter.update", + "description": "Replaces an existing file at the specified path with a new one.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: `update` and `select`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "path", + "isOptional": false, + "type": "String", + "description": "The relative file path. Should be of the format folder/subfolder/filename.png. The bucket must already exist before attempting to update." + }, + { + "name": "file", + "isOptional": false, + "type": "File or Uint8List", + "description": "File object to be stored in the bucket." + }, + { + "name": "fileOptions", + "isOptional": true, + "type": "FileOptions", + "subContent": [ + { + "name": "cacheControl", + "isOptional": true, + "type": "String", + "description": "The number of seconds the asset is cached in the browser and in the Supabase CDN. This is set in the `Cache-Control` header as `max-age=`. Defaults to 3600 seconds." + }, + { + "name": "upsert", + "isOptional": true, + "type": "bool", + "description": "When upsert is set to true, the file is overwritten if it exists. When set to false, an error is thrown if the object already exists. Defaults to false." + }, + { + "name": "contentType", + "isOptional": true, + "type": "String", + "description": "The `Content-Type` header value. Gets parsed with MediaType.parse(mime). Throws a FormatError if the media type is invalid." + } + ] + }, + { + "name": "retryAttempts", + "isOptional": true, + "type": "int", + "description": "Sets the retryAttempts parameter set across the storage client. Defaults to 10." + }, + { + "name": "retryController", + "isOptional": true, + "type": "StorageRetryController", + "description": "Pass a RetryController instance and call `cancel()` to cancel the retry attempts." + } + ], + "examples": [ + { + "id": "update-file", + "name": "Update file", + "isSpotlight": true, + "code": "```dart\nfinal avatarFile = File('path/to/local/file');\nfinal String path = await supabase.storage.from('avatars').update(\n 'public/avatar1.png',\n avatarFile,\n fileOptions: const FileOptions(cacheControl: '3600', upsert: false),\n );\n```\n", + "response": "```json\n'avatars/public/avatar1.png'\n```\n" + }, + { + "id": "update-file-on-web", + "name": "Update file on web", + "code": "```dart\nfinal Uint8List avatarFile = file.bytes;\nfinal String path = await supabase.storage.from('avatars').updateBinary(\n 'public/avatar1.png',\n avatarFile,\n fileOptions: const FileOptions(cacheControl: '3600', upsert: false),\n );\n```\n", + "response": "```json\n'avatars/public/avatar1.png'\n```\n" + } + ] + }, + { + "id": "file-buckets-updatebucket", + "$ref": "supabase_flutter.updateBucket", + "description": "Updates a new Storage bucket\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: `update`\n - `objects` permissions: none\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "id", + "isOptional": false, + "type": "String", + "description": "A unique identifier for the bucket you are updating." + }, + { + "name": "bucketOptions", + "isOptional": false, + "type": "BucketOptions", + "description": "A parameter to optionally make the bucket public.", + "subContent": [ + { + "name": "public", + "isOptional": false, + "type": "bool", + "description": "The visibility of the bucket. Public buckets don't require an authorization token to download objects, but still require a valid token for all other operations. By default, buckets are private." + }, + { + "name": "fileSizeLimit", + "isOptional": true, + "type": "String", + "description": "Specifies the max file size in bytes that can be uploaded to this bucket. The global file size limit takes precedence over this value. The default value is null, which doesn't set a per bucket file size limit." + }, + { + "name": "allowedMimeTypes", + "isOptional": true, + "type": "List", + "description": "Specifies the allowed mime types that this bucket can accept during upload. The default value is null, which allows files with all mime types to be uploaded. Each mime type specified can be a wildcard, e.g. image/*, or a specific mime type, e.g. image/png." + } + ] + } + ], + "examples": [ + { + "id": "update-bucket", + "name": "Update bucket", + "isSpotlight": true, + "code": "```dart\nfinal String res = await supabase\n .storage\n .updateBucket('avatars', const BucketOptions(public: false));\n```\n", + "response": "```json\n'Successfully updated'\n```\n" + } + ] + }, + { + "id": "file-buckets-upload", + "$ref": "supabase_flutter.upload", + "description": "Uploads a file to an existing bucket.\n", + "notes": "- Policy permissions required:\n - `buckets` permissions: none\n - `objects` permissions: `insert`\n- Refer to the [Storage guide](https://supabase.com/docs/guides/storage/security/access-control) on how access control works\n", + "params": [ + { + "name": "path", + "isOptional": false, + "type": "String", + "description": "The relative file path. Should be of the format folder/subfolder/filename.png. The bucket must already exist before attempting to update." + }, + { + "name": "file", + "isOptional": false, + "type": "File or Uint8List", + "description": "File object to be stored in the bucket." + }, + { + "name": "fileOptions", + "isOptional": true, + "type": "FileOptions", + "subContent": [ + { + "name": "cacheControl", + "isOptional": true, + "type": "String", + "description": "The number of seconds the asset is cached in the browser and in the Supabase CDN. This is set in the `Cache-Control` header as `max-age=`. Defaults to 3600 seconds." + }, + { + "name": "upsert", + "isOptional": true, + "type": "bool", + "description": "When upsert is set to true, the file is overwritten if it exists. When set to false, an error is thrown if the object already exists. Defaults to false." + }, + { + "name": "contentType", + "isOptional": true, + "type": "String", + "description": "The `Content-Type` header value. Gets parsed with MediaType.parse(mime). Throws a FormatError if the media type is invalid." + } + ] + }, + { + "name": "retryAttempts", + "isOptional": true, + "type": "int", + "description": "Sets the retryAttempts parameter set across the storage client. Defaults to 10." + }, + { + "name": "retryController", + "isOptional": true, + "type": "StorageRetryController", + "description": "Pass a RetryController instance and call `cancel()` to cancel the retry attempts." + } + ], + "examples": [ + { + "id": "upload-file", + "name": "Upload file", + "isSpotlight": true, + "code": "```dart\nfinal avatarFile = File('path/to/file');\nfinal String fullPath = await supabase.storage.from('avatars').upload(\n 'public/avatar1.png',\n avatarFile,\n fileOptions: const FileOptions(cacheControl: '3600', upsert: false),\n );\n```\n", + "response": "```json\n'avatars/public/avatar1.png'\n```\n" + }, + { + "id": "upload-file-on-web", + "name": "Upload file on web", + "code": "```dart\nfinal Uint8List avatarFile = file.bytes;\nfinal String fullPath = await supabase.storage.from('avatars').uploadBinary(\n 'public/avatar1.png',\n avatarFile,\n fileOptions: const FileOptions(cacheControl: '3600', upsert: false),\n );\n```\n", + "response": "```json\n'avatars/public/avatar1.png'\n```\n" + } + ] + } + ], + "typeSpec": { + "methods": {}, + "variables": {} + } +} \ No newline at end of file diff --git a/apps/docs/scripts/__snapshots__/build-reference-content.v2.json b/apps/docs/scripts/__snapshots__/build-reference-content.v2.json index f15c6ac0e19..d551a84d8f7 100644 --- a/apps/docs/scripts/__snapshots__/build-reference-content.v2.json +++ b/apps/docs/scripts/__snapshots__/build-reference-content.v2.json @@ -1345,6 +1345,20 @@ "product": "storage", "type": "function" }, + "file-buckets-purgebucketcache": { + "id": "file-buckets-purgebucketcache", + "title": "purgeBucketCache", + "slug": "file-buckets-purgebucketcache", + "product": "storage", + "type": "function" + }, + "file-buckets-purgecache": { + "id": "file-buckets-purgecache", + "title": "purgeCache", + "slug": "file-buckets-purgecache", + "product": "storage", + "type": "function" + }, "file-buckets-remove": { "id": "file-buckets-remove", "title": "remove", @@ -1709,6 +1723,8 @@ "[Circular]", "[Circular]", "[Circular]", + "[Circular]", + "[Circular]", "[Circular]" ], "sections": [ @@ -2034,6 +2050,8 @@ "[Circular]", "[Circular]", "[Circular]", + "[Circular]", + "[Circular]", "[Circular]" ] }, @@ -2896,6 +2914,14 @@ "id": "file-buckets-move", "$ref": "@supabase/storage-js.packages/StorageFileApi.default.move" }, + { + "id": "file-buckets-purgebucketcache", + "$ref": "@supabase/storage-js.StorageClient.purgeBucketCache" + }, + { + "id": "file-buckets-purgecache", + "$ref": "@supabase/storage-js.packages/StorageFileApi.default.purgeCache" + }, { "id": "file-buckets-remove", "$ref": "@supabase/storage-js.packages/StorageFileApi.default.remove" @@ -27781,6 +27807,20 @@ "shortText": "OAuth client secret (write-only, not returned in responses)" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_url", "type": { @@ -28038,6 +28078,20 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -28510,6 +28564,17 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -28883,6 +28948,17 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -29263,6 +29339,20 @@ "shortText": "OAuth client secret (write-only, not returned in responses)" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_url", "type": { @@ -29467,6 +29557,17 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -35210,6 +35311,9 @@ "type": { "type": "nameOnly", "name": "TableName" + }, + "comment": { + "shortText": "The table or view name to query" } } ], @@ -35230,6 +35334,9 @@ "type": { "type": "nameOnly", "name": "ViewName" + }, + "comment": { + "shortText": "The table or view name to query" } } ], @@ -35749,6 +35856,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -35780,6 +35890,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -35830,6 +35943,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -35861,6 +35977,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -35881,6 +36000,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -35912,6 +36034,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -35962,6 +36087,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -35993,6 +36121,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -36206,6 +36337,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36318,6 +36452,9 @@ "value": "not.imatch" } ] + }, + "comment": { + "shortText": "The operator to filter with, following PostgREST syntax" } }, { @@ -36325,6 +36462,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -36366,6 +36506,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36373,6 +36516,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The operator to filter with, following PostgREST syntax" } }, { @@ -36380,6 +36526,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -36413,6 +36562,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36420,6 +36572,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -36452,6 +36607,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36459,6 +36617,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -36479,6 +36640,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36486,6 +36650,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -36517,6 +36684,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36524,6 +36694,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -36544,6 +36717,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36551,6 +36727,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -36582,6 +36761,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36589,6 +36771,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -36609,6 +36794,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36619,6 +36807,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -36639,6 +36830,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36649,6 +36843,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -36669,6 +36866,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36679,6 +36879,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -36699,6 +36902,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36709,6 +36915,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -36773,10 +36982,16 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { - "name": "value" + "name": "value", + "comment": { + "shortText": "The value to filter with" + } } ], "ret": { @@ -36808,6 +37023,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36824,6 +37042,9 @@ "name": "boolean" } ] + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -36874,6 +37095,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36881,6 +37105,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -36912,6 +37139,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36919,6 +37149,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -36939,6 +37172,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36949,6 +37185,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -36969,6 +37208,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -36979,6 +37221,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -36999,6 +37244,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37009,6 +37257,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -37029,6 +37280,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37039,6 +37293,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -37136,6 +37393,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37143,6 +37403,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -37174,6 +37437,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37181,6 +37447,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -37201,6 +37470,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37208,6 +37480,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -37239,6 +37514,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37246,6 +37524,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -37274,6 +37555,9 @@ "type": "nameOnly", "name": "Row['ColumnName']" } + }, + "comment": { + "shortText": "The object to filter with, with column names as keys mapped\nto their filter values" } } ], @@ -37313,6 +37597,9 @@ "type": "intrinsic", "name": "unknown" } + }, + "comment": { + "shortText": "The object to filter with, with column names as keys mapped\nto their filter values" } } ], @@ -37412,6 +37699,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37419,6 +37709,9 @@ "type": { "type": "literal", "value": "is" + }, + "comment": { + "shortText": "The operator to be negated to filter with, following\nPostgREST syntax" } }, { @@ -37426,6 +37719,9 @@ "type": { "type": "literal", "value": null + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -37452,6 +37748,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37459,6 +37758,9 @@ "type": { "type": "nameOnly", "name": "FilterOperator" + }, + "comment": { + "shortText": "The operator to be negated to filter with, following\nPostgREST syntax" } }, { @@ -37466,6 +37768,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -37483,6 +37788,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -37490,6 +37798,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The operator to be negated to filter with, following\nPostgREST syntax" } }, { @@ -37497,6 +37808,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -37637,6 +37951,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -37650,7 +37967,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -37658,7 +37978,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -37666,11 +37989,17 @@ "type": "intrinsic", "name": "undefined" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -37721,6 +38050,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -37734,7 +38066,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -37742,7 +38077,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -37750,11 +38088,17 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -37874,6 +38218,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The array or range column to filter on" } }, { @@ -37893,6 +38240,9 @@ } } ] + }, + "comment": { + "shortText": "The array or range value to filter with" } } ], @@ -37934,6 +38284,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The array or range column to filter on" } }, { @@ -37953,6 +38306,9 @@ } } ] + }, + "comment": { + "shortText": "The array or range value to filter with" } } ], @@ -38100,6 +38456,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38107,6 +38466,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38139,6 +38501,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38146,6 +38511,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38166,6 +38534,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38173,6 +38544,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38205,6 +38579,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38212,6 +38589,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38232,6 +38612,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38239,6 +38622,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38271,6 +38657,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38278,6 +38667,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38298,6 +38690,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38305,6 +38700,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38337,6 +38735,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38344,6 +38745,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38364,6 +38768,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38371,6 +38778,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38403,6 +38813,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -38410,6 +38823,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -38430,6 +38846,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -38437,6 +38856,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -38457,6 +38879,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -38464,6 +38889,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -38484,6 +38912,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -38491,6 +38922,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -38511,6 +38945,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -38518,6 +38955,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -38735,6 +39175,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The text or tsvector column to filter on" } }, { @@ -38742,6 +39185,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The query text to match with" } }, { @@ -38755,7 +39201,10 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "The text search configuration to use" + } }, { "name": "type", @@ -38776,11 +39225,17 @@ } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Change how the `query` text is interpreted" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -38830,6 +39285,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The text or tsvector column to filter on" } }, { @@ -38837,6 +39295,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The query text to match with" } }, { @@ -38850,7 +39311,10 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "The text search configuration to use" + } }, { "name": "type", @@ -38871,11 +39335,17 @@ } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Change how the `query` text is interpreted" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -40312,6 +40782,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -40325,7 +40798,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -40333,7 +40809,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -40341,11 +40820,17 @@ "type": "intrinsic", "name": "undefined" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -40396,6 +40881,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -40409,7 +40897,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -40417,7 +40908,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -40425,11 +40919,17 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -41019,6 +41519,14 @@ }, "isOptional": true }, + { + "name": "replication_ready", + "type": { + "type": "intrinsic", + "name": "boolean" + }, + "isOptional": true + }, { "name": "self", "type": { @@ -41031,7 +41539,7 @@ }, "isOptional": true, "comment": { - "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages" + "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages\nreplication_ready option instructs the server to emit a `system` event once the\nPostgres replication connection backing this channel is established and ready to\nstream changes. Listen for it with `channel.on('system', {}, (payload) => ...)`;\nthe payload's `status` is `'ok'` (`message: 'Replication connection established'`)\non success or `'error'` if the connection is not ready in time." } }, { @@ -41269,7 +41777,7 @@ } }, "comment": { - "shortText": "Creates an event handler that listens to changes.", + "shortText": "Listen for presence events on this channel — when peers join, leave, or\nsync presence state.", "text": "- By default, Broadcast and Presence are enabled for all projects.\n- By default, listening to database changes is disabled for new projects due to database performance and security concerns. You can turn it on by managing Realtime's [replication](/docs/guides/api#realtime-api-overview).\n- You can receive the \"previous\" data for updates and deletes by setting the table's `REPLICA IDENTITY` to `FULL` (e.g., `ALTER TABLE your_table REPLICA IDENTITY FULL;`).\n- Row level security is not applied to delete statements. When RLS is enabled and replica identity is set to full, only the primary key is sent to clients.", "examples": [ { @@ -41653,12 +42161,21 @@ { "name": "filter", "type": { - "type": "intrinsic", - "name": "string" + "type": "union", + "subTypes": [ + { + "type": "intrinsic", + "name": "string" + }, + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -41671,6 +42188,27 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": { @@ -41755,10 +42293,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -41768,6 +42315,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -41835,10 +42400,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -41848,6 +42422,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -41915,10 +42507,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -41928,6 +42529,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -42012,10 +42631,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -42025,6 +42653,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -43317,6 +43963,11 @@ }, "isOptional": true }, + { + "name": "replication_ready", + "type": "[Circular]", + "isOptional": true + }, { "name": "self", "type": "[Circular]", @@ -43326,7 +43977,7 @@ }, "isOptional": true, "comment": { - "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages" + "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages\nreplication_ready option instructs the server to emit a `system` event once the\nPostgres replication connection backing this channel is established and ready to\nstream changes. Listen for it with `channel.on('system', {}, (payload) => ...)`;\nthe payload's `status` is `'ok'` (`message: 'Replication connection established'`)\non success or `'error'` if the connection is not ready in time." } }, { @@ -43765,6 +44416,589 @@ ] } }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.constructor": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.constructor", + "params": [], + "ret": { + "type": { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.build": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.build", + "params": [], + "ret": { + "type": { + "type": "intrinsic", + "name": "string" + } + }, + "comment": { + "shortText": "Serialize all conditions into the comma-separated (AND) filter string.\n\nConditions are joined by commas, which the server applies as `AND`. A scalar\nvalue (or single `in` element) that contains a reserved character — `,`,\n`(`, `)`, `\"`, `` — or surrounding whitespace is double-quoted and escaped\nthe way PostgREST does, so commas inside a value are preserved rather than\nread as a condition boundary." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.eq": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.eq", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` equals `value` (`column=eq.value`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.gt": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.gt", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is greater than `value` (`column=gt.value`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.gte": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.gte", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is greater than or equal to `value` (`column=gte.value`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.ilike": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.ilike", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the case-insensitive `pattern` (`column=ilike.pattern`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.imatch": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.imatch", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the case-insensitive POSIX regex `pattern` (`column=imatch.pattern`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.in": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.in", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "values", + "type": { + "type": "array", + "elemType": { + "type": "union", + "subTypes": [ + { + "type": "intrinsic", + "name": "string" + }, + { + "type": "intrinsic", + "name": "number" + }, + { + "type": "intrinsic", + "name": "boolean" + } + ] + } + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is one of `values` (`column=in.(a,b,c)`).\nRequires at least one value; duplicates are removed. An element containing a\nreserved character is double-quoted (`in.(\"a,b\",c)`), so commas inside an\nelement are preserved. `null` is intentionally not accepted (`IN (null)`\nnever matches in SQL) — use `is`/`not('col','is',null)` for null checks." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.is": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.is", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeIsFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` `IS` the given value (`column=is.null`).\nAccepts `null`, a boolean, or the keywords `'null' | 'true' | 'false' | 'unknown'`." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.isDistinct": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.isDistinct", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is distinct from `value` (`column=isdistinct.value`). NULL-safe inequality." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.like": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.like", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the case-sensitive `pattern` (`column=like.pattern`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.lt": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.lt", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is less than `value` (`column=lt.value`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.lte": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.lte", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is less than or equal to `value` (`column=lte.value`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.match": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.match", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the POSIX regex `pattern` (`column=match.pattern`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.neq": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.neq", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` does not equal `value` (`column=neq.value`)." + } + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.not": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.not", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "operator", + "type": { + "type": "literal", + "value": "in" + } + }, + { + "name": "value", + "type": { + "type": "array", + "elemType": { + "type": "union", + "subTypes": [ + { + "type": "intrinsic", + "name": "string" + }, + { + "type": "intrinsic", + "name": "number" + }, + { + "type": "intrinsic", + "name": "boolean" + } + ] + } + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Negate any operator with the `not.` prefix (`column=not.operator.value`).\n`in` takes an array, `is` takes an `IS` keyword/boolean/null, and every\nother operator takes a scalar value.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\npostgresChangesFilter().not('status', 'in', ['draft', 'archived'])\n// → status=not.in.(draft,archived)\npostgresChangesFilter().not('deleted_at', 'is', null)\n// → deleted_at=not.is.null\n```" + } + ] + }, + "altSignatures": [ + { + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "operator", + "type": { + "type": "literal", + "value": "is" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeIsFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + } + }, + { + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "operator", + "type": { + "type": "union", + "subTypes": [ + { + "type": "literal", + "value": "eq" + }, + { + "type": "literal", + "value": "neq" + }, + { + "type": "literal", + "value": "lt" + }, + { + "type": "literal", + "value": "lte" + }, + { + "type": "literal", + "value": "gt" + }, + { + "type": "literal", + "value": "gte" + }, + { + "type": "literal", + "value": "like" + }, + { + "type": "literal", + "value": "ilike" + }, + { + "type": "literal", + "value": "match" + }, + { + "type": "literal", + "value": "imatch" + }, + { + "type": "literal", + "value": "isdistinct" + } + ] + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + } + } + ] + }, + "@supabase/realtime-js.RealtimePostgresFilterBuilder.toString": { + "name": "@supabase/realtime-js.RealtimePostgresFilterBuilder.toString", + "params": [], + "ret": { + "type": { + "type": "intrinsic", + "name": "string" + } + }, + "comment": { + "shortText": "Alias for build; lets the builder be used wherever a string is expected." + } + }, "@supabase/realtime-js.RealtimePresence.constructor": { "name": "@supabase/realtime-js.RealtimePresence.constructor", "params": [ @@ -44306,6 +45540,26 @@ } } }, + "@supabase/realtime-js.postgresChangesFilter": { + "name": "@supabase/realtime-js.postgresChangesFilter", + "params": [], + "ret": { + "type": { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + }, + "comment": { + "shortText": "Create a RealtimePostgresFilterBuilder for composing a Postgres\nChanges `filter`. Conditions are combined with `AND`.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nimport { postgresChangesFilter } from '@supabase/realtime-js'\n\nchannel.on('postgres_changes', {\n event: 'UPDATE',\n schema: 'public',\n table: 'orders',\n filter: postgresChangesFilter().gt('amount', 100).eq('status', 'open'),\n}, (payload) => { ... })\n```" + } + ] + } + }, "@supabase/storage-js.StorageApiError.constructor": { "name": "@supabase/storage-js.StorageApiError.constructor", "params": [ @@ -45237,6 +46491,177 @@ ] } }, + "@supabase/storage-js.StorageClient.purgeBucketCache": { + "name": "@supabase/storage-js.StorageClient.purgeBucketCache", + "params": [ + { + "name": "id", + "type": { + "type": "intrinsic", + "name": "string" + }, + "comment": { + "shortText": "The unique identifier of the bucket you would like to purge from cache." + } + }, + { + "name": "options", + "type": { + "type": "object", + "name": "PurgeCacheOptions", + "properties": [ + { + "name": "transformations", + "type": { + "type": "literal", + "value": true + }, + "isOptional": true, + "comment": { + "shortText": "If true, purges only the transformations (resized/formatted variants) for the object or bucket,\nleaving the original cached file intact. If omitted, purges all cached versions." + } + } + ] + }, + "isOptional": true, + "comment": { + "shortText": "Optional purge cache options." + } + }, + { + "name": "parameters", + "type": { + "type": "object", + "name": "FetchParameters", + "properties": [ + { + "name": "cache", + "type": { + "type": "union", + "subTypes": [ + { + "type": "literal", + "value": "default" + }, + { + "type": "literal", + "value": "no-store" + }, + { + "type": "literal", + "value": "reload" + }, + { + "type": "literal", + "value": "no-cache" + }, + { + "type": "literal", + "value": "force-cache" + }, + { + "type": "literal", + "value": "only-if-cached" + } + ] + }, + "isOptional": true, + "comment": { + "shortText": "Controls how the request interacts with the browser's HTTP cache.\n- 'default': Use standard cache behavior\n- 'no-store': Bypass cache entirely (useful in Edge Functions)\n- 'reload': Bypass cache but update it with response\n- 'no-cache': Validate with server before using cached response\n- 'force-cache': Use cache even if stale\n- 'only-if-cached': Only use cache, fail if not cached" + } + }, + { + "name": "signal", + "type": { + "type": "nameOnly", + "name": "AbortSignal" + }, + "isOptional": true, + "comment": { + "shortText": "Pass in an AbortController's signal to cancel the request." + } + } + ] + }, + "isOptional": true, + "comment": { + "shortText": "Optional fetch parameters such as an `AbortController` signal." + } + } + ], + "ret": { + "type": { + "type": "promise", + "name": "Promise", + "awaited": { + "type": "union", + "subTypes": [ + { + "type": "object", + "properties": [ + { + "name": "data", + "type": { + "type": "object", + "properties": [ + { + "name": "message", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ] + } + }, + { + "name": "error", + "type": { + "type": "literal", + "value": null + } + } + ] + }, + { + "type": "object", + "properties": [ + { + "name": "data", + "type": { + "type": "literal", + "value": null + } + }, + { + "name": "error", + "type": { + "type": "nameOnly", + "name": "StorageError" + } + } + ] + } + ] + } + } + }, + "comment": { + "shortText": "Purges the CDN cache for an entire bucket.\n\nMaps to `DELETE /cdn/{bucket}` on the Storage API. The server\nissues a CDN invalidation for the bucket and returns `{ message: 'success' }`.\n\n**Requires the `service_role` key.** The underlying endpoint enforces\n`service_role` JWT — calls made with the anon key or a user JWT will be\nrejected by the server.\n\n**Hosted CDN feature.** On self-hosted Supabase, the Storage service must\nhave `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant\nfeature enabled, otherwise the server returns an error.", + "examples": [ + { + "id": "purge-cache-for-an-entire-bucket", + "name": "Purge cache for an entire bucket", + "code": "```js\nconst { data, error } = await supabase\n .storage\n .purgeBucketCache('avatars')\n```" + }, + { + "id": "purge-only-transformations-for-an-entire-bucket", + "name": "Purge only transformations for an entire bucket", + "code": "```js\nconst { data, error } = await supabase\n .storage\n .purgeBucketCache('avatars', { transformations: true })\n```" + } + ] + } + }, "@supabase/storage-js.StorageClient.setHeader": { "name": "@supabase/storage-js.StorageClient.setHeader", "params": [ @@ -47642,6 +49067,156 @@ ] } }, + "@supabase/storage-js.packages/StorageBucketApi.default.purgeBucketCache": { + "name": "@supabase/storage-js.packages/StorageBucketApi.default.purgeBucketCache", + "params": [ + { + "name": "id", + "type": { + "type": "intrinsic", + "name": "string" + }, + "comment": { + "shortText": "The unique identifier of the bucket you would like to purge from cache." + } + }, + { + "name": "options", + "type": { + "type": "object", + "name": "PurgeCacheOptions", + "properties": [ + { + "name": "transformations", + "type": "[Circular]", + "isOptional": true, + "comment": { + "shortText": "If true, purges only the transformations (resized/formatted variants) for the object or bucket,\nleaving the original cached file intact. If omitted, purges all cached versions." + } + } + ] + }, + "isOptional": true, + "comment": { + "shortText": "Optional purge cache options." + } + }, + { + "name": "parameters", + "type": { + "type": "object", + "name": "FetchParameters", + "properties": [ + { + "name": "cache", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + }, + "isOptional": true, + "comment": { + "shortText": "Controls how the request interacts with the browser's HTTP cache.\n- 'default': Use standard cache behavior\n- 'no-store': Bypass cache entirely (useful in Edge Functions)\n- 'reload': Bypass cache but update it with response\n- 'no-cache': Validate with server before using cached response\n- 'force-cache': Use cache even if stale\n- 'only-if-cached': Only use cache, fail if not cached" + } + }, + { + "name": "signal", + "type": { + "type": "nameOnly", + "name": "AbortSignal" + }, + "isOptional": true, + "comment": { + "shortText": "Pass in an AbortController's signal to cancel the request." + } + } + ] + }, + "isOptional": true, + "comment": { + "shortText": "Optional fetch parameters such as an `AbortController` signal." + } + } + ], + "ret": { + "type": { + "type": "promise", + "name": "Promise", + "awaited": { + "type": "union", + "subTypes": [ + { + "type": "object", + "properties": [ + { + "name": "data", + "type": { + "type": "object", + "properties": [ + { + "name": "message", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ] + } + }, + { + "name": "error", + "type": { + "type": "literal", + "value": null + } + } + ] + }, + { + "type": "object", + "properties": [ + { + "name": "data", + "type": { + "type": "literal", + "value": null + } + }, + { + "name": "error", + "type": { + "type": "nameOnly", + "name": "StorageError" + } + } + ] + } + ] + } + } + }, + "comment": { + "shortText": "Purges the CDN cache for an entire bucket.\n\nMaps to `DELETE /cdn/{bucket}` on the Storage API. The server\nissues a CDN invalidation for the bucket and returns `{ message: 'success' }`.\n\n**Requires the `service_role` key.** The underlying endpoint enforces\n`service_role` JWT — calls made with the anon key or a user JWT will be\nrejected by the server.\n\n**Hosted CDN feature.** On self-hosted Supabase, the Storage service must\nhave `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant\nfeature enabled, otherwise the server returns an error.", + "examples": [ + { + "id": "purge-cache-for-an-entire-bucket", + "name": "Purge cache for an entire bucket", + "code": "```js\nconst { data, error } = await supabase\n .storage\n .purgeBucketCache('avatars')\n```" + }, + { + "id": "purge-only-transformations-for-an-entire-bucket", + "name": "Purge only transformations for an entire bucket", + "code": "```js\nconst { data, error } = await supabase\n .storage\n .purgeBucketCache('avatars', { transformations: true })\n```" + } + ] + } + }, "@supabase/storage-js.packages/StorageBucketApi.default.setHeader": { "name": "@supabase/storage-js.packages/StorageBucketApi.default.setHeader", "params": [ @@ -48594,30 +50169,12 @@ "type": { "type": "union", "subTypes": [ - { - "type": "literal", - "value": "default" - }, - { - "type": "literal", - "value": "no-store" - }, - { - "type": "literal", - "value": "reload" - }, - { - "type": "literal", - "value": "no-cache" - }, - { - "type": "literal", - "value": "force-cache" - }, - { - "type": "literal", - "value": "only-if-cached" - } + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" ] }, "isOptional": true, @@ -49975,6 +51532,156 @@ ] } }, + "@supabase/storage-js.packages/StorageFileApi.default.purgeCache": { + "name": "@supabase/storage-js.packages/StorageFileApi.default.purgeCache", + "params": [ + { + "name": "path", + "type": { + "type": "intrinsic", + "name": "string" + }, + "comment": { + "shortText": "The path (relative to the bucket) of the object to purge, e.g. `folder/avatar.png`." + } + }, + { + "name": "options", + "type": { + "type": "object", + "name": "PurgeCacheOptions", + "properties": [ + { + "name": "transformations", + "type": "[Circular]", + "isOptional": true, + "comment": { + "shortText": "If true, purges only the transformations (resized/formatted variants) for the object or bucket,\nleaving the original cached file intact. If omitted, purges all cached versions." + } + } + ] + }, + "isOptional": true, + "comment": { + "shortText": "Optional purge cache options." + } + }, + { + "name": "parameters", + "type": { + "type": "object", + "name": "FetchParameters", + "properties": [ + { + "name": "cache", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]", + "[Circular]" + ] + }, + "isOptional": true, + "comment": { + "shortText": "Controls how the request interacts with the browser's HTTP cache.\n- 'default': Use standard cache behavior\n- 'no-store': Bypass cache entirely (useful in Edge Functions)\n- 'reload': Bypass cache but update it with response\n- 'no-cache': Validate with server before using cached response\n- 'force-cache': Use cache even if stale\n- 'only-if-cached': Only use cache, fail if not cached" + } + }, + { + "name": "signal", + "type": { + "type": "nameOnly", + "name": "AbortSignal" + }, + "isOptional": true, + "comment": { + "shortText": "Pass in an AbortController's signal to cancel the request." + } + } + ] + }, + "isOptional": true, + "comment": { + "shortText": "Optional fetch parameters such as an `AbortController` signal." + } + } + ], + "ret": { + "type": { + "type": "promise", + "name": "Promise", + "awaited": { + "type": "union", + "subTypes": [ + { + "type": "object", + "properties": [ + { + "name": "data", + "type": { + "type": "object", + "properties": [ + { + "name": "message", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ] + } + }, + { + "name": "error", + "type": { + "type": "literal", + "value": null + } + } + ] + }, + { + "type": "object", + "properties": [ + { + "name": "data", + "type": { + "type": "literal", + "value": null + } + }, + { + "name": "error", + "type": { + "type": "nameOnly", + "name": "StorageError" + } + } + ] + } + ] + } + } + }, + "comment": { + "shortText": "Purges the CDN cache for a single object in this bucket.\n\nMaps to `DELETE /cdn/{bucket}/{path}` on the Storage API. The server\nissues a CDN invalidation for the object and returns `{ message: 'success' }`.\n\n**Requires the `service_role` key.** The underlying endpoint enforces\n`service_role` JWT — calls made with the anon key or a user JWT will be\nrejected by the server.\n\n**Hosted CDN feature.** On self-hosted Supabase, the Storage service must\nhave `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant\nfeature enabled, otherwise the server returns an error.\n\nOperates on a single object path. There is no wildcard or recursion: pass\nthe exact path of the object you want invalidated.", + "examples": [ + { + "id": "purge-a-single-cached-object", + "name": "Purge a single cached object", + "code": "```js\nconst { data, error } = await supabase\n .storage\n .from('avatars')\n .purgeCache('folder/avatar1.png')\n```" + }, + { + "id": "purge-only-transformations-for-a-single-object", + "name": "Purge only transformations for a single object", + "code": "```js\nconst { data, error } = await supabase\n .storage\n .from('avatars')\n .purgeCache('folder/avatar1.png', { transformations: true })\n```" + } + ] + } + }, "@supabase/storage-js.packages/StorageFileApi.default.remove": { "name": "@supabase/storage-js.packages/StorageFileApi.default.remove", "params": [ @@ -78996,6 +80703,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -79027,6 +80737,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -79036,6 +80749,39 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for jsonb, array, and range columns. Match only rows where\nevery element appearing in `column` is contained by `value`.", + "examples": [ + { + "id": "on-array-columns", + "name": "On array columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('classes')\n .select('name')\n .containedBy('days', ['monday', 'tuesday', 'wednesday', 'friday'])\n```", + "data": { + "sql": "```sql\ncreate table\n classes (\n id int8 primary key,\n name text,\n days text[]\n );\n\ninsert into\n classes (id, name, days)\nvalues\n (1, 'Chemistry', array['monday', 'friday']),\n (2, 'History', array['monday', 'wednesday', 'thursday']);\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"name\": \"Chemistry\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + }, + { + "id": "on-range-columns", + "name": "On range columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .containedBy('during', '[2000-01-01 00:00, 2000-01-01 23:59)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"room_name\": \"Emerald\",\n \"during\": \"[\\\"2000-01-01 13:00:00\\\",\\\"2000-01-01 15:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + }, + { + "id": "on-jsonb-columns", + "name": "On `jsonb` columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('users')\n .select('name')\n .containedBy('address', {})\n```", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Michael', '{ \"postcode\": 90210, \"street\": \"Melrose Place\" }'),\n (2, 'Jane', '{}');\n```" + }, + "response": "```json\n {\n \"data\": [\n {\n \"name\": \"Jane\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n }\n\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79044,6 +80790,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -79075,6 +80824,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -79095,6 +80847,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -79126,6 +80881,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -79135,6 +80893,39 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for jsonb, array, and range columns. Match only rows where\n`column` contains every element appearing in `value`.", + "examples": [ + { + "id": "on-array-columns", + "name": "On array columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('issues')\n .select()\n .contains('tags', ['is:open', 'priority:low'])\n```", + "data": { + "sql": "```sql\ncreate table\n issues (\n id int8 primary key,\n title text,\n tags text[]\n );\n\ninsert into\n issues (id, title, tags)\nvalues\n (1, 'Cache invalidation is not working', array['is:open', 'severity:high', 'priority:low']),\n (2, 'Use better names', array['is:open', 'severity:low', 'priority:medium']);\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"title\": \"Cache invalidation is not working\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + }, + { + "id": "on-range-columns", + "name": "On range columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .contains('during', '[2000-01-01 13:00, 2000-01-01 13:30)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"room_name\": \"Emerald\",\n \"during\": \"[\\\"2000-01-01 13:00:00\\\",\\\"2000-01-01 15:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + }, + { + "id": "on-jsonb-columns", + "name": "On `jsonb` columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('users')\n .select('name')\n .contains('address', { postcode: 90210 })\n```", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Michael', '{ \"postcode\": 90210, \"street\": \"Melrose Place\" }'),\n (2, 'Jane', '{}');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"name\": \"Michael\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79143,6 +80934,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The jsonb, array, or range column to filter on" } }, { @@ -79174,6 +80968,9 @@ } } ] + }, + "comment": { + "shortText": "The jsonb, array, or range value to filter with" } } ], @@ -79388,6 +81185,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79500,6 +81300,9 @@ "value": "not.imatch" } ] + }, + "comment": { + "shortText": "The operator to filter with, following PostgREST syntax" } }, { @@ -79507,6 +81310,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -79516,6 +81322,30 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows which satisfy the filter. This is an escape hatch - you\nshould use the specific filter methods wherever possible.\n\nUnlike most filters, `opearator` and `value` are used as-is and need to\nfollow [PostgREST\nsyntax](https://postgrest.org/en/stable/api.html#operators). You also need\nto make sure they are properly sanitized.", + "text": "filter() expects you to use the raw PostgREST syntax for the filter values.\n\n```ts\n.filter('id', 'in', '(5,6,7)') // Use `()` for `in` filter\n.filter('arraycol', 'cs', '{\"a\",\"b\"}') // Use `cs` for `contains()`, `{}` for array values\n```", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select()\n .filter('name', 'in', '(\"Han\",\"Yoda\")')\n```", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 3,\n \"name\": \"Han\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + }, + { + "id": "on-a-referenced-table", + "name": "On a referenced table", + "code": "```ts\nconst { data, error } = await supabase\n .from('orchestral_sections')\n .select(`\n name,\n instruments!inner (\n name\n )\n `)\n .filter('instruments.name', 'eq', 'flute')\n```", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 2, 'flute'),\n (2, 1, 'violin');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"name\": \"woodwinds\",\n \"instruments\": [\n {\n \"name\": \"flute\"\n }\n ]\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79524,6 +81354,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79531,6 +81364,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The operator to filter with, following PostgREST syntax" } }, { @@ -79538,6 +81374,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -79571,6 +81410,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79578,6 +81420,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -79587,6 +81432,21 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` is greater than `value`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select()\n .gt('id', 2)\n```", + "description": "When using [reserved words](https://www.postgresql.org/docs/current/sql-keywords-appendix.html) for column names you need\nto add double quotes e.g. `.gt('\"order\"', 2)`", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 3,\n \"name\": \"Han\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79595,6 +81455,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79602,6 +81465,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -79622,6 +81488,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79629,6 +81498,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -79638,6 +81510,20 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` is greater than or equal to `value`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select()\n .gte('id', 2)\n```", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 2,\n \"name\": \"Leia\"\n },\n {\n \"id\": 3,\n \"name\": \"Han\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79646,6 +81532,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79653,6 +81542,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -79673,6 +81565,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79680,6 +81575,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -79689,6 +81587,20 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches `pattern` case-insensitively.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select()\n .ilike('name', '%lu%')\n```", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"Luke\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79697,6 +81609,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79704,6 +81619,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -79724,6 +81642,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79734,6 +81655,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -79743,6 +81667,9 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches all of `patterns` case-insensitively." + }, "altSignatures": [ { "params": [ @@ -79751,6 +81678,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79761,6 +81691,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -79781,6 +81714,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79791,6 +81727,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -79800,6 +81739,9 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches any of `patterns` case-insensitively." + }, "altSignatures": [ { "params": [ @@ -79808,6 +81750,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79818,6 +81763,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -79882,10 +81830,16 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { - "name": "value" + "name": "value", + "comment": { + "shortText": "The value to filter with" + } } ], "ret": { @@ -79894,6 +81848,21 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` IS `value`.\n\nFor non-boolean columns, this is only relevant for checking if the value of\n`column` is NULL by setting `value` to `null`.\n\nFor boolean columns, you can also set `value` to `true` or `false` and it\nwill behave the same way as `.eq()`.", + "examples": [ + { + "id": "checking-for-nullness-true-or-false", + "name": "Checking for nullness, true or false", + "code": "```ts\nconst { data, error } = await supabase\n .from('countries')\n .select()\n .is('name', null)\n```", + "description": "Using the `eq()` filter doesn't work when filtering for `null`.\n\nInstead, you need to use `is()`.", + "data": { + "sql": "```sql\ncreate table\n countries (id int8 primary key, name text);\n\ninsert into\n countries (id, name)\nvalues\n (1, 'null'),\n (2, null);\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 2,\n \"name\": \"null\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79902,6 +81871,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79918,6 +81890,9 @@ "name": "boolean" } ] + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -79968,6 +81943,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79975,6 +81953,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -79984,6 +81965,20 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches `pattern` case-sensitively.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select()\n .like('name', '%Lu%')\n```", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"Luke\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -79992,6 +81987,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -79999,6 +81997,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The pattern to match with" } } ], @@ -80019,6 +82020,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80029,6 +82033,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -80038,6 +82045,9 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches all of `patterns` case-sensitively." + }, "altSignatures": [ { "params": [ @@ -80046,6 +82056,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80056,6 +82069,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -80076,6 +82092,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80086,6 +82105,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -80095,6 +82117,9 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches any of `patterns` case-sensitively." + }, "altSignatures": [ { "params": [ @@ -80103,6 +82128,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80113,6 +82141,9 @@ "type": "intrinsic", "name": "string" } + }, + "comment": { + "shortText": "The patterns to match with" } } ], @@ -80211,6 +82242,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80218,6 +82252,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -80227,6 +82264,20 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` is less than `value`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select()\n .lt('id', 2)\n```", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"Luke\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -80235,6 +82286,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80242,6 +82296,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -80262,6 +82319,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80269,6 +82329,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -80278,6 +82341,20 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` is less than or equal to `value`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select()\n .lte('id', 2)\n```", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"name\": \"Luke\"\n },\n {\n \"id\": 2,\n \"name\": \"Leia\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -80286,6 +82363,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80293,6 +82373,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with" } } ], @@ -80321,6 +82404,9 @@ "type": "nameOnly", "name": "Row['ColumnName']" } + }, + "comment": { + "shortText": "The object to filter with, with column names as keys mapped\nto their filter values" } } ], @@ -80330,6 +82416,20 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where each column in `query` keys is equal to its\nassociated value. Shorthand for multiple `.eq()`s.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('characters')\n .select('name')\n .match({ id: 2, name: 'Leia' })\n```", + "data": { + "sql": "```sql\ncreate table\n characters (id int8 primary key, name text);\n\ninsert into\n characters (id, name)\nvalues\n (1, 'Luke'),\n (2, 'Leia'),\n (3, 'Han');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"name\": \"Leia\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -80346,6 +82446,9 @@ "type": "intrinsic", "name": "unknown" } + }, + "comment": { + "shortText": "The object to filter with, with column names as keys mapped\nto their filter values" } } ], @@ -80445,6 +82548,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80452,6 +82558,9 @@ "type": { "type": "literal", "value": "is" + }, + "comment": { + "shortText": "The operator to be negated to filter with, following\nPostgREST syntax" } }, { @@ -80459,9 +82568,15 @@ "type": { "type": "literal", "value": null + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], + "comment": { + "shortText": "Match only rows which doesn't satisfy the filter.\n\nUnlike most filters, `opearator` and `value` are used as-is and need to\nfollow [PostgREST\nsyntax](https://postgrest.org/en/stable/api.html#operators). You also need\nto make sure they are properly sanitized." + }, "altSignatures": [ { "params": [ @@ -80470,6 +82585,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80477,6 +82595,9 @@ "type": { "type": "nameOnly", "name": "FilterOperator" + }, + "comment": { + "shortText": "The operator to be negated to filter with, following\nPostgREST syntax" } }, { @@ -80484,6 +82605,9 @@ "type": { "type": "nameOnly", "name": "Row['ColumnName']" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -80501,6 +82625,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -80508,6 +82635,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The operator to be negated to filter with, following\nPostgREST syntax" } }, { @@ -80515,6 +82645,9 @@ "type": { "type": "intrinsic", "name": "unknown" + }, + "comment": { + "shortText": "The value to filter with, following PostgREST syntax" } } ], @@ -80656,6 +82789,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -80669,7 +82805,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -80677,7 +82816,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -80685,11 +82827,17 @@ "type": "intrinsic", "name": "undefined" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -80698,6 +82846,9 @@ "name": "this" } }, + "comment": { + "shortText": "Order the query result by `column`.\n\nYou can call this method multiple times to order by multiple columns.\n\nYou can order referenced tables, but it only affects the ordering of the\nparent table if you use `!inner` in the query." + }, "altSignatures": [ { "params": [ @@ -80706,6 +82857,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -80719,7 +82873,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -80727,7 +82884,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -80735,11 +82895,17 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -80859,6 +83025,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The array or range column to filter on" } }, { @@ -80878,6 +83047,9 @@ } } ] + }, + "comment": { + "shortText": "The array or range value to filter with" } } ], @@ -80887,6 +83059,30 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for array and range columns. Match only rows where\n`column` and `value` have an element in common.", + "examples": [ + { + "id": "on-array-columns", + "name": "On array columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('issues')\n .select('title')\n .overlaps('tags', ['is:closed', 'severity:high'])\n```", + "data": { + "sql": "```sql\ncreate table\n issues (\n id int8 primary key,\n title text,\n tags text[]\n );\n\ninsert into\n issues (id, title, tags)\nvalues\n (1, 'Cache invalidation is not working', array['is:open', 'severity:high', 'priority:low']),\n (2, 'Use better names', array['is:open', 'severity:low', 'priority:medium']);\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"title\": \"Cache invalidation is not working\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + }, + { + "id": "on-range-columns", + "name": "On range columns", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .overlaps('during', '[2000-01-01 12:45, 2000-01-01 13:15)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"room_name\": \"Emerald\",\n \"during\": \"[\\\"2000-01-01 13:00:00\\\",\\\"2000-01-01 15:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -80895,6 +83091,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The array or range column to filter on" } }, { @@ -80914,6 +83113,9 @@ } } ] + }, + "comment": { + "shortText": "The array or range value to filter with" } } ], @@ -81062,6 +83264,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81069,6 +83274,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81078,6 +83286,21 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for range columns. Match only rows where `column` is\nmutually exclusive to `range` and there can be no element between the two\nranges.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .rangeAdjacent('during', '[2000-01-01 12:00, 2000-01-01 13:00)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"room_name\": \"Emerald\",\n \"during\": \"[\\\"2000-01-01 13:00:00\\\",\\\"2000-01-01 15:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -81086,6 +83309,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81093,6 +83319,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81113,6 +83342,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81120,6 +83352,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81129,6 +83364,21 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for range columns. Match only rows where every element in\n`column` is greater than any element in `range`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .rangeGt('during', '[2000-01-02 08:00, 2000-01-02 09:00)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n {\n \"data\": [\n {\n \"id\": 2,\n \"room_name\": \"Topaz\",\n \"during\": \"[\\\"2000-01-02 09:00:00\\\",\\\"2000-01-02 10:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n }\n\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -81137,6 +83387,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81144,6 +83397,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81164,6 +83420,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81171,6 +83430,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81180,6 +83442,21 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for range columns. Match only rows where every element in\n`column` is either contained in `range` or greater than any element in\n`range`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .rangeGte('during', '[2000-01-02 08:30, 2000-01-02 09:30)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n {\n \"data\": [\n {\n \"id\": 2,\n \"room_name\": \"Topaz\",\n \"during\": \"[\\\"2000-01-02 09:00:00\\\",\\\"2000-01-02 10:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n }\n\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -81188,6 +83465,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81195,6 +83475,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81215,6 +83498,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81222,6 +83508,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81231,6 +83520,21 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for range columns. Match only rows where every element in\n`column` is less than any element in `range`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .rangeLt('during', '[2000-01-01 15:00, 2000-01-01 16:00)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"id\": 1,\n \"room_name\": \"Emerald\",\n \"during\": \"[\\\"2000-01-01 13:00:00\\\",\\\"2000-01-01 15:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -81239,6 +83543,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81246,6 +83553,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81266,6 +83576,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81273,6 +83586,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81282,6 +83598,21 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for range columns. Match only rows where every element in\n`column` is either contained in `range` or less than any element in\n`range`.", + "examples": [ + { + "id": "with-select", + "name": "With `select()`", + "code": "```ts\nconst { data, error } = await supabase\n .from('reservations')\n .select()\n .rangeLte('during', '[2000-01-01 14:00, 2000-01-01 16:00)')\n```", + "description": "Postgres supports a number of [range\ntypes](https://www.postgresql.org/docs/current/rangetypes.html). You\ncan filter on range columns using the string representation of range\nvalues.", + "data": { + "sql": "```sql\ncreate table\n reservations (\n id int8 primary key,\n room_name text,\n during tsrange\n );\n\ninsert into\n reservations (id, room_name, during)\nvalues\n (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'),\n (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)');\n```" + }, + "response": "```json\n {\n \"data\": [\n {\n \"id\": 1,\n \"room_name\": \"Emerald\",\n \"during\": \"[\\\"2000-01-01 13:00:00\\\",\\\"2000-01-01 15:00:00\\\")\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n }\n\n```" + } + ] + }, "altSignatures": [ { "params": [ @@ -81290,6 +83621,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range column to filter on" } }, { @@ -81297,6 +83631,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The range to filter with" } } ], @@ -81317,6 +83654,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -81324,6 +83664,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -81333,6 +83676,9 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches the PostgreSQL regex `pattern`\ncase-insensitively (using the `~*` operator)." + }, "altSignatures": [ { "params": [ @@ -81341,6 +83687,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -81348,6 +83697,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -81368,6 +83720,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -81375,6 +83730,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -81384,6 +83742,9 @@ "name": "this" } }, + "comment": { + "shortText": "Match only rows where `column` matches the PostgreSQL regex `pattern`\ncase-sensitively (using the `~` operator)." + }, "altSignatures": [ { "params": [ @@ -81392,6 +83753,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to filter on" } }, { @@ -81399,6 +83763,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The PostgreSQL regular expression pattern to match with" } } ], @@ -81616,6 +83983,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The text or tsvector column to filter on" } }, { @@ -81623,6 +83993,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The query text to match with" } }, { @@ -81636,7 +84009,10 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "The text search configuration to use" + } }, { "name": "type", @@ -81657,11 +84033,17 @@ } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Change how the `query` text is interpreted" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -81670,6 +84052,39 @@ "name": "this" } }, + "comment": { + "shortText": "Only relevant for text and tsvector columns. Match only rows where\n`column` matches the query string in `query`.", + "text": "- For more information, see [Postgres full text search](/docs/guides/database/full-text-search).", + "examples": [ + { + "id": "text-search", + "name": "Text search", + "code": "```ts\nconst result = await supabase\n .from(\"texts\")\n .select(\"content\")\n .textSearch(\"content\", `'eggs' & 'ham'`, {\n config: \"english\",\n });\n```", + "data": { + "sql": "```sql\ncreate table texts (\n id bigint\n primary key\n generated always as identity,\n content text\n);\n\ninsert into texts (content) values\n ('Four score and seven years ago'),\n ('The road goes ever on and on'),\n ('Green eggs and ham')\n;\n```" + }, + "response": "```json\n{\n \"data\": [\n {\n \"content\": \"Green eggs and ham\"\n }\n ],\n \"status\": 200,\n \"statusText\": \"OK\"\n}\n```" + }, + { + "id": "basic-normalization", + "name": "Basic normalization", + "code": "```ts\nconst { data, error } = await supabase\n .from('quotes')\n .select('catchphrase')\n .textSearch('catchphrase', `'fat' & 'cat'`, {\n type: 'plain',\n config: 'english'\n })\n```", + "description": "Uses PostgreSQL's `plainto_tsquery` function." + }, + { + "id": "full-normalization", + "name": "Full normalization", + "code": "```ts\nconst { data, error } = await supabase\n .from('quotes')\n .select('catchphrase')\n .textSearch('catchphrase', `'fat' & 'cat'`, {\n type: 'phrase',\n config: 'english'\n })\n```", + "description": "Uses PostgreSQL's `phraseto_tsquery` function." + }, + { + "id": "websearch", + "name": "Websearch", + "code": "```ts\nconst { data, error } = await supabase\n .from('quotes')\n .select('catchphrase')\n .textSearch('catchphrase', `'fat or cat'`, {\n type: 'websearch',\n config: 'english'\n })\n```", + "description": "Uses PostgreSQL's `websearch_to_tsquery` function.\nThis function will never raise syntax errors, which makes it possible to use raw user-supplied input for search, and can be used\nwith advanced operators.\n\n- `unquoted text`: text not inside quote marks will be converted to terms separated by & operators, as if processed by plainto_tsquery.\n- `\"quoted text\"`: text inside quote marks will be converted to terms separated by `<->` operators, as if processed by phraseto_tsquery.\n- `OR`: the word “or” will be converted to the | operator.\n- `-`: a dash will be converted to the ! operator." + } + ] + }, "altSignatures": [ { "params": [ @@ -81678,6 +84093,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The text or tsvector column to filter on" } }, { @@ -81685,6 +84103,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The query text to match with" } }, { @@ -81698,7 +84119,10 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "The text search configuration to use" + } }, { "name": "type", @@ -81719,11 +84143,17 @@ } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Change how the `query` text is interpreted" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -83166,6 +85596,9 @@ "type": { "type": "nameOnly", "name": "ColumnName" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -83179,7 +85612,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -83187,7 +85623,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -83195,11 +85634,17 @@ "type": "intrinsic", "name": "undefined" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -83208,6 +85653,9 @@ "name": "this" } }, + "comment": { + "shortText": "Order the query result by `column`.\n\nYou can call this method multiple times to order by multiple columns.\n\nYou can order referenced tables, but it only affects the ordering of the\nparent table if you use `!inner` in the query." + }, "altSignatures": [ { "params": [ @@ -83216,6 +85664,9 @@ "type": { "type": "intrinsic", "name": "string" + }, + "comment": { + "shortText": "The column to order by" } }, { @@ -83229,7 +85680,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, the result will be in ascending order" + } }, { "name": "nullsFirst", @@ -83237,7 +85691,10 @@ "type": "intrinsic", "name": "boolean" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "If `true`, `null`s appear first. If `false`,\n`null`s appear last." + } }, { "name": "referencedTable", @@ -83245,11 +85702,17 @@ "type": "intrinsic", "name": "string" }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Set this to order a referenced table by\nits columns" + } } ] }, - "isOptional": true + "isOptional": true, + "comment": { + "shortText": "Named parameters" + } } ], "ret": { @@ -83847,6 +86310,14 @@ }, "isOptional": true }, + { + "name": "replication_ready", + "type": { + "type": "intrinsic", + "name": "boolean" + }, + "isOptional": true + }, { "name": "self", "type": { @@ -83859,7 +86330,7 @@ }, "isOptional": true, "comment": { - "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages" + "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages\nreplication_ready option instructs the server to emit a `system` event once the\nPostgres replication connection backing this channel is established and ready to\nstream changes. Listen for it with `channel.on('system', {}, (payload) => ...)`;\nthe payload's `status` is `'ok'` (`message: 'Replication connection established'`)\non success or `'error'` if the connection is not ready in time." } }, { @@ -84100,7 +86571,7 @@ } }, "comment": { - "shortText": "Creates an event handler that listens to changes." + "shortText": "Listen for presence events on this channel — when peers join, leave, or\nsync presence state." }, "altSignatures": [ { @@ -84422,12 +86893,21 @@ { "name": "filter", "type": { - "type": "intrinsic", - "name": "string" + "type": "union", + "subTypes": [ + { + "type": "intrinsic", + "name": "string" + }, + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -84440,6 +86920,27 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": { @@ -84524,10 +87025,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -84537,6 +87047,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -84604,10 +87132,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -84617,6 +87154,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -84684,10 +87239,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -84697,6 +87261,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -84781,10 +87363,19 @@ }, { "name": "filter", - "type": "[Circular]", + "type": { + "type": "union", + "subTypes": [ + "[Circular]", + { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + ] + }, "isOptional": true, "comment": { - "shortText": "Receive database changes when filter is matched." + "shortText": "Receive database changes only when the filter is matched.\n\nA filter is a `column=operator.value` expression, e.g. `id=eq.1` or\n`title=like.%foo%`. See RealtimePostgresChangesFilterOperator for\nthe available operators.\n\nMultiple filters can be combined with commas; they are applied as an `AND`\ncondition: `filter: 'id=gt.0,id=lt.100'`.\n\nAny operator can be negated with the `not.` prefix: `filter: 'status=not.in.(draft,archived)'`.\n\nThe server splits conditions on commas outside quotes/parentheses. To\ninclude a reserved character (`,`, `(`, `)`) in a value, wrap it in double\nquotes PostgREST-style: `name=eq.\"a,b\"`. The RealtimePostgresFilterBuilder\ndoes this quoting for you.\n\nInstead of a raw string you can pass a RealtimePostgresFilterBuilder\n(via `postgresChangesFilter()`) for a type-checked, ergonomic way to compose filters; the\nSDK serializes it to a string automatically." } }, { @@ -84794,6 +87385,24 @@ "shortText": "The database schema to listen to." } }, + { + "name": "select", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Restrict the change payload to a subset of columns instead of receiving the\nfull row. Reduces payload size (helpful for large `bytea`/`jsonb` columns)\nand the data transferred per event.\n\nThe listed columns must be selectable by the subscribing role.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nchannel.on('postgres_changes', {\n event: '*',\n schema: 'public',\n table: 'users',\n select: ['id', 'first_name'],\n}, (payload) => {\n // payload.new only contains { id, first_name }\n})\n```" + } + ] + } + }, { "name": "table", "type": "[Circular]", @@ -85441,6 +88050,10 @@ "type": { "type": "union", "subTypes": [ + { + "type": "literal", + "value": "postgres_changes" + }, { "type": "literal", "value": "broadcast" @@ -85448,10 +88061,6 @@ { "type": "literal", "value": "presence" - }, - { - "type": "literal", - "value": "postgres_changes" } ] }, @@ -86091,6 +88700,11 @@ }, "isOptional": true }, + { + "name": "replication_ready", + "type": "[Circular]", + "isOptional": true + }, { "name": "self", "type": "[Circular]", @@ -86100,7 +88714,7 @@ }, "isOptional": true, "comment": { - "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages" + "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages\nreplication_ready option instructs the server to emit a `system` event once the\nPostgres replication connection backing this channel is established and ready to\nstream changes. Listen for it with `channel.on('system', {}, (payload) => ...)`;\nthe payload's `status` is `'ok'` (`message: 'Replication connection established'`)\non success or `'error'` if the connection is not ready in time." } }, { @@ -86541,6 +89155,589 @@ ] } }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.constructor": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.constructor", + "params": [], + "ret": { + "type": { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.build": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.build", + "params": [], + "ret": { + "type": { + "type": "intrinsic", + "name": "string" + } + }, + "comment": { + "shortText": "Serialize all conditions into the comma-separated (AND) filter string.\n\nConditions are joined by commas, which the server applies as `AND`. A scalar\nvalue (or single `in` element) that contains a reserved character — `,`,\n`(`, `)`, `\"`, `` — or surrounding whitespace is double-quoted and escaped\nthe way PostgREST does, so commas inside a value are preserved rather than\nread as a condition boundary." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.eq": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.eq", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` equals `value` (`column=eq.value`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.gt": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.gt", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is greater than `value` (`column=gt.value`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.gte": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.gte", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is greater than or equal to `value` (`column=gte.value`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.ilike": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.ilike", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the case-insensitive `pattern` (`column=ilike.pattern`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.imatch": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.imatch", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the case-insensitive POSIX regex `pattern` (`column=imatch.pattern`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.in": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.in", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "values", + "type": { + "type": "array", + "elemType": { + "type": "union", + "subTypes": [ + { + "type": "intrinsic", + "name": "string" + }, + { + "type": "intrinsic", + "name": "number" + }, + { + "type": "intrinsic", + "name": "boolean" + } + ] + } + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is one of `values` (`column=in.(a,b,c)`).\nRequires at least one value; duplicates are removed. An element containing a\nreserved character is double-quoted (`in.(\"a,b\",c)`), so commas inside an\nelement are preserved. `null` is intentionally not accepted (`IN (null)`\nnever matches in SQL) — use `is`/`not('col','is',null)` for null checks." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.is": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.is", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeIsFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` `IS` the given value (`column=is.null`).\nAccepts `null`, a boolean, or the keywords `'null' | 'true' | 'false' | 'unknown'`." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.isDistinct": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.isDistinct", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is distinct from `value` (`column=isdistinct.value`). NULL-safe inequality." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.like": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.like", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the case-sensitive `pattern` (`column=like.pattern`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.lt": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.lt", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is less than `value` (`column=lt.value`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.lte": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.lte", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` is less than or equal to `value` (`column=lte.value`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.match": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.match", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "pattern", + "type": { + "type": "intrinsic", + "name": "string" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` matches the POSIX regex `pattern` (`column=match.pattern`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.neq": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.neq", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Match rows where `column` does not equal `value` (`column=neq.value`)." + } + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.not": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.not", + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "operator", + "type": { + "type": "literal", + "value": "in" + } + }, + { + "name": "value", + "type": { + "type": "array", + "elemType": { + "type": "union", + "subTypes": [ + { + "type": "intrinsic", + "name": "string" + }, + { + "type": "intrinsic", + "name": "number" + }, + { + "type": "intrinsic", + "name": "boolean" + } + ] + } + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + }, + "comment": { + "shortText": "Negate any operator with the `not.` prefix (`column=not.operator.value`).\n`in` takes an array, `is` takes an `IS` keyword/boolean/null, and every\nother operator takes a scalar value.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\npostgresChangesFilter().not('status', 'in', ['draft', 'archived'])\n// → status=not.in.(draft,archived)\npostgresChangesFilter().not('deleted_at', 'is', null)\n// → deleted_at=not.is.null\n```" + } + ] + }, + "altSignatures": [ + { + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "operator", + "type": { + "type": "literal", + "value": "is" + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeIsFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + } + }, + { + "params": [ + { + "name": "column", + "type": { + "type": "intrinsic", + "name": "string" + } + }, + { + "name": "operator", + "type": { + "type": "union", + "subTypes": [ + { + "type": "literal", + "value": "match" + }, + { + "type": "literal", + "value": "eq" + }, + { + "type": "literal", + "value": "neq" + }, + { + "type": "literal", + "value": "gt" + }, + { + "type": "literal", + "value": "gte" + }, + { + "type": "literal", + "value": "lt" + }, + { + "type": "literal", + "value": "lte" + }, + { + "type": "literal", + "value": "like" + }, + { + "type": "literal", + "value": "ilike" + }, + { + "type": "literal", + "value": "isdistinct" + }, + { + "type": "literal", + "value": "imatch" + } + ] + } + }, + { + "name": "value", + "type": { + "type": "nameOnly", + "name": "RealtimeFilterValue" + } + } + ], + "ret": { + "type": { + "type": "intrinsic", + "name": "this" + } + } + } + ] + }, + "@supabase/supabase-js.RealtimePostgresFilterBuilder.toString": { + "name": "@supabase/supabase-js.RealtimePostgresFilterBuilder.toString", + "params": [], + "ret": { + "type": { + "type": "intrinsic", + "name": "string" + } + }, + "comment": { + "shortText": "Alias for build; lets the builder be used wherever a string is expected." + } + }, "@supabase/supabase-js.RealtimePresence.constructor": { "name": "@supabase/supabase-js.RealtimePresence.constructor", "params": [ @@ -87441,6 +90638,11 @@ }, "isOptional": true }, + { + "name": "replication_ready", + "type": "[Circular]", + "isOptional": true + }, { "name": "self", "type": "[Circular]", @@ -87450,7 +90652,7 @@ }, "isOptional": true, "comment": { - "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages" + "shortText": "self option enables client to receive message it broadcast\nack option instructs server to acknowledge that broadcast message was received\nreplay option instructs server to replay broadcast messages\nreplication_ready option instructs the server to emit a `system` event once the\nPostgres replication connection backing this channel is established and ready to\nstream changes. Listen for it with `channel.on('system', {}, (payload) => ...)`;\nthe payload's `status` is `'ok'` (`message: 'Replication connection established'`)\non success or `'error'` if the connection is not ready in time." } }, { @@ -89837,6 +93039,20 @@ "shortText": "OAuth client secret (write-only, not returned in responses)" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_url", "type": { @@ -90094,6 +93310,20 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -90566,6 +93796,17 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -90939,6 +94180,17 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -91319,6 +94571,20 @@ "shortText": "OAuth client secret (write-only, not returned in responses)" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": { + "type": "intrinsic", + "name": "string" + } + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_url", "type": { @@ -91523,6 +94789,17 @@ "shortText": "Timestamp when the provider was created" } }, + { + "name": "custom_claims_allowlist", + "type": { + "type": "array", + "elemType": "[Circular]" + }, + "isOptional": true, + "comment": { + "shortText": "Allowlist of raw identity provider claim keys to copy verbatim into the\nuser's `custom_claims` field (within `identity_data` and\n`raw_user_meta_data`), e.g. `[\"groups\", \"org_id\", \"mail\"]`. This is an\nopt-in allowlist that defaults to empty (no claims captured) and operates\nindependently from `attribute_mapping`." + } + }, { "name": "discovery_document", "type": { @@ -97631,6 +100908,26 @@ "shortText": "Implements a global exclusive lock using the Navigator LockManager API. It\nis available on all browsers released after 2022-03-15 with Safari being the\nlast one to release support. If the API is not available, this function will\nthrow. Make sure you check availablility before configuring GoTrueClient.\n\nYou can turn on debugging by setting the `supabase.gotrue-js.locks.debug`\nlocal storage item to `true`.\n\nInternals:\n\nSince the LockManager API does not preserve stack traces for the async\nfunction passed in the `request` method, a trick is used where acquiring the\nlock releases a previously started promise to run the operation in the `fn`\nfunction. The lock waits for that promise to finish (with or without error),\nwhile the function will finally wait for the result anyway." } }, + "@supabase/supabase-js.postgresChangesFilter": { + "name": "@supabase/supabase-js.postgresChangesFilter", + "params": [], + "ret": { + "type": { + "type": "nameOnly", + "name": "RealtimePostgresFilterBuilder" + } + }, + "comment": { + "shortText": "Create a RealtimePostgresFilterBuilder for composing a Postgres\nChanges `filter`. Conditions are combined with `AND`.", + "examples": [ + { + "id": "example-1", + "name": "Example 1", + "code": "```ts\nimport { postgresChangesFilter } from '@supabase/realtime-js'\n\nchannel.on('postgres_changes', {\n event: 'UPDATE',\n schema: 'public',\n table: 'orders',\n filter: postgresChangesFilter().gt('amount', 100).eq('status', 'open'),\n}, (payload) => { ... })\n```" + } + ] + } + }, "@supabase/supabase-js.processLock": { "name": "@supabase/supabase-js.processLock", "params": [ diff --git a/apps/docs/scripts/build-reference-content.test.ts b/apps/docs/scripts/build-reference-content.test.ts index deaa4afdd74..b9ab38ac3a0 100644 --- a/apps/docs/scripts/build-reference-content.test.ts +++ b/apps/docs/scripts/build-reference-content.test.ts @@ -1,6 +1,26 @@ -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { collectReferenceContent } from './build-reference-content' +import { generateDartReferenceDump } from './generate-dart-reference' + +function serialize(content: { + bySlug: unknown + flat: unknown + sections: unknown + functionsList: unknown + typeSpec: unknown +}): string { + const seen = new WeakSet() + const breakCycles = (_key: string, value: unknown) => { + if (value && typeof value === 'object') { + if (seen.has(value as object)) return '[Circular]' + seen.add(value as object) + } + return value + } + const { bySlug, flat, sections, functionsList, typeSpec } = content + return JSON.stringify({ bySlug, flat, sections, functionsList, typeSpec }, breakCycles, 2) +} /** * Regression guard for the new reference-content pipeline. Snapshots the five @@ -15,22 +35,42 @@ import { collectReferenceContent } from './build-reference-content' * doesn't collapse deep / cyclic structures (typeSpec contains * self-referencing builder types) into `[Object]` placeholders — which would * make param renames, signature changes, and JSDoc edits invisible. + * + * --- + * + * Update on Jun 30th, 2026. Skipping this until we figure out a better way to + * avoid downloaded typedoc files to block build on unrelated PRs. */ -describe('build-reference-content — javascript/v2', () => { +describe.skip('build-reference-content: javascript/v2', () => { it('matches snapshot', async () => { - const { bySlug, flat, sections, functionsList, typeSpec } = await collectReferenceContent( - 'javascript', - 'v2' + const content = await collectReferenceContent('javascript', 'v2') + await expect(serialize(content)).toMatchFileSnapshot( + './__snapshots__/build-reference-content.v2.json' + ) + }) +}) + +/** + * Dart/v2 has no upstream TypeDoc dump; its source is regenerated from the + * committed `spec/supabase_dart_v2.yml` by `generate-dart-reference.ts`. We run + * that converter first so the snapshot covers the full conversion + build path + * (YAML to dump to content) and surfaces any drift in either step. + * + * --- + * + * Update on Jun 30th, 2026. Skipping this for the same reason as the + * javascript/v2 suite above, until snapshot updates are decoupled from + * unrelated builds. + */ +describe.skip('build-reference-content: dart/v2', () => { + beforeAll(async () => { + await generateDartReferenceDump() + }) + + it('matches snapshot', async () => { + const content = await collectReferenceContent('dart', 'v2') + await expect(serialize(content)).toMatchFileSnapshot( + './__snapshots__/build-reference-content.dart.v2.json' ) - const seen = new WeakSet() - const breakCycles = (_key: string, value: unknown) => { - if (value && typeof value === 'object') { - if (seen.has(value as object)) return '[Circular]' - seen.add(value as object) - } - return value - } - const json = JSON.stringify({ bySlug, flat, sections, functionsList, typeSpec }, breakCycles, 2) - await expect(json).toMatchFileSnapshot('./__snapshots__/build-reference-content.v2.json') }) }) diff --git a/apps/docs/scripts/build-reference-content.ts b/apps/docs/scripts/build-reference-content.ts index 3fe27d4a053..6c2c37e6880 100644 --- a/apps/docs/scripts/build-reference-content.ts +++ b/apps/docs/scripts/build-reference-content.ts @@ -63,6 +63,8 @@ interface Declaration { parameters?: Param[] type?: unknown flags?: { isOptional?: boolean; isConst?: boolean } + // Non-TypeDoc field: pre-rendered legacy function content (see FunctionEntry.content). + content?: Record } interface FunctionEntry { @@ -70,6 +72,14 @@ interface FunctionEntry { category: string subcategory: string | null $ref: string + /** + * Optional pre-rendered content (description, notes, params, examples) carried + * directly on a declaration. TypeDoc never emits this; it exists so non-TypeDoc + * sources adapted "as a pre-step" (e.g. the Dart YAML converter) can ship the + * legacy function shape straight onto the functions.json entry without a + * typeSpec round-trip. Absent for real TypeDoc dumps, so JS output is unchanged. + */ + content?: Record } interface FunctionsEntry { @@ -363,7 +373,7 @@ function collectFunctions( if (category) { const subcategory = readTagFromDeclOrSignature(node, '@subcategory') const $ref = normalizeRefPath([ctx.pkg, ...ctx.path, node.name].join('.')) - out.functions.push({ name: node.name, category, subcategory, $ref }) + out.functions.push({ name: node.name, category, subcategory, $ref, content: node.content }) } } @@ -519,7 +529,9 @@ function buildBySlug( items.push(entry) // functions.json `id` must match the bySlug entry's `id` (not the slug) — // the renderer in Reference.sections.tsx does `fns.find(f => f.id === section.id)`. - functionsList.push({ id: entry.id, $ref: fn.$ref }) + // `fn.content`, when present, carries the legacy function shape for non-TypeDoc + // sources; spread it so the renderer reads those fields without a typeSpec. + functionsList.push({ id: entry.id, $ref: fn.$ref, ...(fn.content ?? {}) }) } // Sort items alphabetically (case-insensitive) within categories and within diff --git a/apps/docs/scripts/generate-dart-reference.ts b/apps/docs/scripts/generate-dart-reference.ts new file mode 100644 index 00000000000..d8119284727 --- /dev/null +++ b/apps/docs/scripts/generate-dart-reference.ts @@ -0,0 +1,253 @@ +/** + * Generate the Dart/Flutter reference dump consumed by the new reference + * pipeline (`scripts/build-reference-content.ts`). + * + * Dart has no upstream TypeDoc output, so this script is the "pre-step" the + * reference README describes: it adapts the hand-authored legacy spec + * (`spec/supabase_dart_v2.yml`) plus the shared section tree + * (`spec/common-client-libs-sections.json`) into a TypeDoc-shaped JSON dump at + * `spec/reference/dart/v2/supabase_flutter.json`. + * + * Each Dart method becomes a `variant: 'declaration'` node tagged with + * `@category` / `@subcategory` (so the build groups it into the right section) + * and carries the legacy function shape (description, notes, params, examples) + * on a non-TypeDoc `content` field. `build-reference-content.ts` spreads that + * straight onto the functions.json entry, so the renderer shows params, + * examples, and notes exactly as the legacy YAML did, with no typeSpec + * round-trip. + * + * Overview/header entries (e.g. "Using filters", "Auth MFA") and the top-level + * markdown sections (introduction, installing, upgrade-guide, initializing) are + * not emitted here. They live as hand-authored partials under + * `spec/reference/dart/v2/partials/`, matching how the JavaScript lib is set up. + * + * The dump is gitignored (like every other reference dump); only `config.json` + * and `partials/` are committed. Run via `pnpm codegen:references:new` (the + * ensure step regenerates this file when it is missing). + * + * Usage: pnpm tsx scripts/generate-dart-reference.ts + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from 'yaml' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const DOCS_DIR = join(__dirname, '..') +const YAML_PATH = join(DOCS_DIR, 'spec/supabase_dart_v2.yml') +const SECTIONS_PATH = join(DOCS_DIR, 'spec/common-client-libs-sections.json') +const VERSION_DIR = join(DOCS_DIR, 'spec/reference/dart/v2') +const CONFIG_PATH = join(VERSION_DIR, 'config.json') +const OUT_PATH = join(VERSION_DIR, 'supabase_flutter.json') + +const LIB_ID = 'reference_dart_v2' + +/** + * Ids whose YAML entry is a category/subcategory overview rather than a method. + * They are rendered through committed JSON partials (keyed by the subcategory + * title slug), so they are skipped when building method declarations. + */ +const HEADER_IDS = new Set([ + 'using-filters', + 'using-modifiers', + 'auth-mfa-api', + 'passkey-api', + 'admin-api', + 'admin-passkey-api', + 'file-buckets', +]) + +/** Top-level entries handled by markdown / top-level JSON partials, not methods. */ +const SKIP_IDS = new Set(['introduction', 'installing', 'upgrade-guide', 'initializing']) + +/** + * Dart-specific ids that are absent from the shared (JS-centric) section tree. + * Map them to the category/subcategory they belong to. + */ +const SECTION_OVERRIDE: Record = { + 'max-affected': { category: 'Database', subcategory: 'Using modifiers' }, + 'link-identity-with-id-token': { category: 'Auth', subcategory: null }, + 'auth-reset-password-for-email': { category: 'Auth', subcategory: null }, +} + +/** Method-name overrides for titles that don't yield a clean identifier. */ +const NAME_OVERRIDE: Record = { + explain: 'explain', +} + +interface SectionNode { + id?: string + title?: string + type?: string + excludes?: string[] + items?: SectionNode[] +} + +interface DartFunction { + id: string + title: string + description?: string + notes?: string + params?: unknown[] + examples?: unknown[] +} + +/** Walk the shared section tree, recording each id's category/subcategory for dart v2. */ +function buildSectionMap( + sections: SectionNode[] +): Map { + const map = new Map() + const included = (n: SectionNode) => !(n.excludes ?? []).includes(LIB_ID) + const walk = (nodes: SectionNode[], category: string | null, subcategory: string | null) => { + for (const node of nodes) { + if (!included(node)) continue + if (node.type === 'category') { + walk(node.items ?? [], node.title ?? '', null) + } else if (node.items && node.items.length) { + walk(node.items, category, node.title ?? null) + } else if (node.id) { + map.set(node.id, { category: category ?? '', subcategory }) + } + } + } + walk(sections, null, null) + return map +} + +/** + * Derive a Dart method identifier from a legacy title: + * 'signUp()' -> 'signUp' + * 'Fetch data: select()' -> 'select' + * 'from.upload()' -> 'upload' + * 'mfa.enroll()' -> 'enroll' + * 'on().subscribe()' -> 'subscribe' + */ +function deriveName(title: string): string { + let name = String(title) + .trim() + .replace(/^['"]|['"]$/g, '') + if (name.includes(': ')) name = name.split(': ').pop() as string + name = name.replace(/\([^)]*\)/g, '') + name = name.split('.').filter(Boolean).pop() ?? name + return name.trim() +} + +function textTag(tag: string, text: string) { + return { tag, content: [{ kind: 'text', text }] } +} + +type NavigationPrefixes = Record + +/** + * Recomputes the slug `build-reference-content.ts` will assign to a method, so + * collisions are caught here (and reported by id) rather than silently dropped + * by the build's first-wins dedup. Mirrors that script's `functionPrefix` / + * slug logic, keyed on the nearest container (subcategory, else category). + */ +function functionSlug( + name: string, + category: string, + subcategory: string | null, + navigationPrefixes: NavigationPrefixes +): string { + const key = subcategory ?? category + const override = navigationPrefixes[key] + const prefix = override === false ? null : typeof override === 'string' ? override : slugify(key) + const nameLower = name.toLowerCase() + return prefix === null ? nameLower : `${prefix}-${nameLower}` +} + +function slugify(value: string): string { + return value.toLowerCase().trim().replace(/\s+/g, '-') +} + +export async function generateDartReferenceDump(): Promise<{ methodCount: number }> { + const doc = parse(await readFile(YAML_PATH, 'utf-8')) as { functions: DartFunction[] } + const sections = JSON.parse(await readFile(SECTIONS_PATH, 'utf-8')) as SectionNode[] + const config = JSON.parse(await readFile(CONFIG_PATH, 'utf-8')) as { + navigationPrefixes?: NavigationPrefixes + } + const navigationPrefixes = config.navigationPrefixes ?? {} + const sectionMap = buildSectionMap(sections) + + let nextId = 1 + const children: unknown[] = [] + const slugOwners = new Map() + const skipped: string[] = [] + + for (const fn of doc.functions) { + if (SKIP_IDS.has(fn.id) || HEADER_IDS.has(fn.id)) continue + + const section = SECTION_OVERRIDE[fn.id] ?? sectionMap.get(fn.id) + if (!section) { + skipped.push(fn.id) + continue + } + + const name = NAME_OVERRIDE[fn.id] ?? deriveName(fn.title) + if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name)) { + throw new Error(`Dart converter: id "${fn.id}" yielded an invalid method name "${name}"`) + } + + const slug = functionSlug(name, section.category, section.subcategory, navigationPrefixes) + const owner = slugOwners.get(slug) + if (owner) { + throw new Error(`Dart converter: ids "${owner}" and "${fn.id}" both map to slug "${slug}"`) + } + slugOwners.set(slug, fn.id) + + const blockTags = [textTag('@category', section.category)] + if (section.subcategory) blockTags.push(textTag('@subcategory', section.subcategory)) + + const content: Record = {} + if (fn.description) content.description = fn.description + if (fn.notes) content.notes = fn.notes + if (fn.params?.length) content.params = fn.params + if (fn.examples?.length) content.examples = fn.examples + + children.push({ + id: nextId++, + name, + variant: 'declaration', + kind: 2048, + flags: {}, + comment: { summary: [], blockTags }, + content, + }) + } + + const dump = { + id: 0, + name: 'supabase_flutter', + variant: 'project', + kind: 1, + flags: {}, + children, + } + + await mkdir(dirname(OUT_PATH), { recursive: true }) + await writeFile(OUT_PATH, JSON.stringify(dump, null, 2)) + + if (skipped.length) { + throw new Error( + `Dart converter: ${skipped.length} ids have no section mapping (add them to ` + + `common-client-libs-sections.json or SECTION_OVERRIDE): ${skipped.join(', ')}` + ) + } + + console.log( + `[dart/v2] wrote ${children.length} method declarations to ${OUT_PATH.replace(DOCS_DIR + '/', '')}` + ) + + return { methodCount: children.length } +} + +// Only run when invoked as a script (via `tsx`); importing from a test must not +// trigger the side-effecting file write. +if (import.meta.url === `file://${process.argv[1]}`) { + generateDartReferenceDump().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/apps/docs/scripts/search/sources/index.ts b/apps/docs/scripts/search/sources/index.ts index e8fb408c0c5..3682cb505b3 100644 --- a/apps/docs/scripts/search/sources/index.ts +++ b/apps/docs/scripts/search/sources/index.ts @@ -52,13 +52,15 @@ export async function fetchJsLibReferenceSource() { } export async function fetchDartLibReferenceSource() { - return new ClientLibReferenceLoader( - 'dart-lib', - '/reference/dart', - { title: 'Dart Reference', language: 'Dart' }, - 'spec/supabase_dart_v2.yml', - 'spec/common-client-libs-sections.json' - ).load() + // Dart v2 is driven by the new reference pipeline. Ingest search sources from + // the generated `content/reference/dart/v2/` outputs so embeddings never + // drift from what the renderer shows. + return loadClientLibReferenceFromNewPipeline({ + source: 'dart-lib', + path: '/reference/dart', + meta: { title: 'Dart Reference', language: 'Dart' }, + contentDir: 'content/reference/dart/v2', + }) } export async function fetchPythonLibReferenceSource() { diff --git a/apps/docs/spec/api_v1_openapi.json b/apps/docs/spec/api_v1_openapi.json index 1af817aed15..df2dd7683e4 100644 --- a/apps/docs/spec/api_v1_openapi.json +++ b/apps/docs/spec/api_v1_openapi.json @@ -4442,7 +4442,67 @@ }, "/v1/projects/{ref}/analytics/endpoints/logs.all": { "get": { - "description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. \n", + "deprecated": true, + "description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources.\n", + "operationId": "v1-get-project-logs-all", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "sql", + "required": false, + "in": "query", + "description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.", + "schema": { "type": "string" } + }, + { + "name": "iso_timestamp_start", + "required": false, + "in": "query", + "schema": { "format": "date-time", "example": "2025-03-01T00:00:00Z", "type": "string" } + }, + { + "name": "iso_timestamp_end", + "required": false, + "in": "query", + "schema": { "format": "date-time", "example": "2025-03-01T23:59:59Z", "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/AnalyticsResponse" } } + } + }, + "401": { "description": "Unauthorized" }, + "402": { "description": "Usage exceeded. Enable additional usage to continue querying" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["analytics_logs_read"] }], + "summary": "Gets project's logs", + "tags": ["Analytics"], + "x-badges": [{ "name": "OAuth scope: analytics:read", "position": "after" }], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics:read" + } + }, + "/v1/projects/{ref}/analytics/endpoints/logs": { + "get": { + "deprecated": false, + "description": "Executes an SQL or LQL query on the project's unified logs stream.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nFilter by the `source` column to specify specific log sources, such as edge_logs, postgres_logs, etc.\n\nNote: SQL must be written in **ClickHouse SQL dialect**.\n", "operationId": "v1-get-project-logs", "parameters": [ { @@ -4486,11 +4546,12 @@ } }, "401": { "description": "Unauthorized" }, + "402": { "description": "Usage exceeded. Enable additional usage to continue querying" }, "403": { "description": "Forbidden action" }, "429": { "description": "Rate limit exceeded" } }, "security": [{ "bearer": [] }, { "fga_permissions": ["analytics_logs_read"] }], - "summary": "Gets project's logs", + "summary": "Gets all project's logs in a single log stream", "tags": ["Analytics"], "x-badges": [{ "name": "OAuth scope: analytics:read", "position": "after" }], "x-endpoint-owners": ["analytics"], @@ -5310,7 +5371,7 @@ "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden action" }, "429": { "description": "Rate limit exceeded" }, - "500": { "description": "Failed to upsert database migration" } + "500": { "description": "Failed to update JIT access" } }, "security": [{ "bearer": [] }, { "fga_permissions": ["database_jit_write"] }], "summary": "Updates a user mapping for JIT access", @@ -5357,6 +5418,137 @@ "x-endpoint-owners": ["security"] } }, + "/v1/projects/{ref}/database/jit/invite": { + "post": { + "description": "Invites the external user and sets initial roles that can be assumed and for how long", + "operationId": "v1-invite-external-jit-access", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InviteExternalUserJitAccessBody" } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InviteExternalUserJitResponse" } + } + } + }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" }, + "500": { "description": "Failed to invite external user" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["database_jit_write"] }], + "summary": "Invites an external user to a database for JIT access", + "tags": ["Database"], + "x-endpoint-owners": ["security"] + } + }, + "/v1/projects/{ref}/database/jit/invite/accept": { + "post": { + "description": "Accepts the invitation to JIT database access", + "operationId": "v1-accept-invite-external-jit-access", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AcceptInviteExternalUserJitAccessBody" } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/JitAccessResponse" } } + } + }, + "500": { "description": "Failed to accept invitation" } + }, + "security": [{ "bearer": [] }], + "summary": "Accepts invitation for JIT database access", + "tags": ["Database"], + "x-endpoint-owners": ["security"] + } + }, + "/v1/projects/{ref}/database/jit/invite/{invite_id}": { + "delete": { + "description": "Revokes and deletes the invitation", + "operationId": "v1-delete-invite-external-jit-access", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "invite_id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "example": "55555555-5555-4555-8555-555555555555", + "type": "string" + } + } + ], + "responses": { + "200": { "description": "" }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden action" }, + "429": { "description": "Rate limit exceeded" }, + "500": { "description": "Failed to revoke invite for external user" } + }, + "security": [{ "bearer": [] }, { "fga_permissions": ["database_jit_write"] }], + "summary": "Deletes the invite for an external user to a database for JIT access", + "tags": ["Database"], + "x-endpoint-owners": ["security"] + } + }, "/v1/projects/{ref}/database/jit/{user_id}": { "delete": { "description": "Remove JIT mappings of a user, revoking all JIT database access", @@ -8553,11 +8745,7 @@ "state": { "type": "string", "enum": ["unavailable"] }, "unavailableReason": { "type": "string", - "enum": [ - "manual_migration_required", - "postgres_upgrade_required", - "temporarily_unavailable" - ] + "enum": ["postgres_upgrade_required", "temporarily_unavailable"] } }, "required": ["state", "unavailableReason"] @@ -10976,7 +11164,7 @@ } } }, - "required": ["user_id", "user_roles"] + "required": ["user_roles"] }, "AuthorizeJitAccessBody": { "type": "object", @@ -11030,44 +11218,94 @@ "items": { "type": "array", "items": { - "type": "object", - "properties": { - "user_id": { "type": "string", "format": "uuid" }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { "type": "string", "minLength": 1 }, - "expires_at": { "type": "number" }, - "allowed_networks": { + "oneOf": [ + { + "type": "object", + "properties": { + "user_id": { "type": "string", "format": "uuid" }, + "primary_email": { "type": "string", "nullable": true }, + "invite_id": { "type": "null" }, + "expires_at": { "type": "null" }, + "user_roles": { + "type": "array", + "items": { "type": "object", "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { "cidr": { "type": "string" } }, - "required": ["cidr"] + "role": { "type": "string", "minLength": 1 }, + "expires_at": { "type": "number" }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + } } }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { "cidr": { "type": "string" } }, - "required": ["cidr"] + "branches_only": { "type": "boolean" } + }, + "required": ["role"] + } + } + }, + "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] + }, + { + "type": "object", + "properties": { + "user_id": { "type": "null" }, + "primary_email": { "type": "string" }, + "invite_id": { "type": "string", "format": "uuid" }, + "expires_at": { "type": "string" }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { "type": "string", "minLength": 1 }, + "expires_at": { "type": "number" }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + } } - } - } - }, - "branches_only": { "type": "boolean" } - }, - "required": ["role"] - } + }, + "branches_only": { "type": "boolean" } + }, + "required": ["role"] + } + } + }, + "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] } - }, - "required": ["user_id", "user_roles"] + ] } } }, @@ -11124,6 +11362,107 @@ ] } }, + "InviteExternalUserJitAccessBody": { + "type": "object", + "properties": { + "email": { "type": "string", "format": "email", "minLength": 1 }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { "type": "string", "minLength": 1 }, + "expires_at": { "type": "number" }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { "type": "boolean" } + }, + "required": ["role"] + } + } + }, + "required": ["email", "roles"], + "example": { + "email": "external-user@somedomain.xyz", + "roles": [ + { + "role": "postgres", + "expires_at": 1740787200, + "allowed_networks": { "allowed_cidrs": [{ "cidr": "203.0.113.0/24" }] }, + "branches_only": false + } + ] + } + }, + "InviteExternalUserJitResponse": { + "type": "object", + "properties": { + "email": { "type": "string", "format": "email" }, + "invite_id": { "type": "string", "format": "uuid" }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { "type": "string", "minLength": 1 }, + "expires_at": { "type": "number" }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { "cidr": { "type": "string" } }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { "type": "boolean" } + }, + "required": ["role"] + } + } + }, + "required": ["email", "invite_id", "user_roles"] + }, + "AcceptInviteExternalUserJitAccessBody": { + "type": "object", + "properties": { + "email": { "type": "string", "format": "email", "minLength": 1 }, + "token": { "type": "string", "minLength": 1 } + }, + "required": ["email", "token"], + "example": { "email": "external-user@somedomain.xyz", "token": "" } + }, "FunctionResponse": { "type": "object", "properties": { @@ -12542,9 +12881,10 @@ "user_name": { "type": "string" }, "email": { "type": "string" }, "role_name": { "type": "string" }, - "mfa_enabled": { "type": "boolean" } + "mfa_enabled": { "type": "boolean" }, + "avatar_url": { "type": "string", "nullable": true } }, - "required": ["user_id", "user_name", "role_name", "mfa_enabled"] + "required": ["user_id", "user_name", "role_name", "mfa_enabled", "avatar_url"] }, "V1OrganizationSlugResponse": { "type": "object", diff --git a/apps/docs/spec/api_v2_openapi.json b/apps/docs/spec/api_v2_openapi.json index a576b8f8cf3..b65f8d3facf 100644 --- a/apps/docs/spec/api_v2_openapi.json +++ b/apps/docs/spec/api_v2_openapi.json @@ -1058,6 +1058,11 @@ "type": "boolean", "description": "Whether this member is a Single Sign-On user" }, + "avatar_url": { + "type": "string", + "nullable": true, + "description": "Member's avatar URL" + }, "roles": { "type": "array", "items": { @@ -1092,7 +1097,14 @@ "description": "Roles assigned to this member. Includes both org-level and project-scoped roles." } }, - "required": ["username", "primary_email", "mfa_enabled", "is_sso_user", "roles"] + "required": [ + "username", + "primary_email", + "mfa_enabled", + "is_sso_user", + "avatar_url", + "roles" + ] } }, "required": ["type", "id", "attributes"] diff --git a/apps/docs/spec/common-api-sections.json b/apps/docs/spec/common-api-sections.json index 254629246b8..643f61d6721 100644 --- a/apps/docs/spec/common-api-sections.json +++ b/apps/docs/spec/common-api-sections.json @@ -52,6 +52,12 @@ "slug": "v1-get-project-logs", "type": "operation" }, + { + "id": "v1-get-project-logs-all", + "title": "Get project logs all", + "slug": "v1-get-project-logs-all", + "type": "operation" + }, { "id": "v1-get-project-usage-api-count", "title": "Get project usage api count", @@ -220,6 +226,12 @@ "type": "category", "title": "Database", "items": [ + { + "id": "v1-accept-invite-external-jit-access", + "title": "Accept invite external jit access", + "slug": "v1-accept-invite-external-jit-access", + "type": "operation" + }, { "id": "v1-apply-a-migration", "title": "Apply a migration", @@ -238,6 +250,12 @@ "slug": "v1-create-login-role", "type": "operation" }, + { + "id": "v1-delete-invite-external-jit-access", + "title": "Delete invite external jit access", + "slug": "v1-delete-invite-external-jit-access", + "type": "operation" + }, { "id": "v1-delete-jit-access", "title": "Delete jit access", @@ -340,6 +358,12 @@ "slug": "v1-get-ssl-enforcement-config", "type": "operation" }, + { + "id": "v1-invite-external-jit-access", + "title": "Invite external jit access", + "slug": "v1-invite-external-jit-access", + "type": "operation" + }, { "id": "v1-list-all-backups", "title": "List all backups", diff --git a/apps/docs/spec/reference/README.md b/apps/docs/spec/reference/README.md index 8c64e956a63..9602a13174e 100644 --- a/apps/docs/spec/reference/README.md +++ b/apps/docs/spec/reference/README.md @@ -135,6 +135,30 @@ Key tags the walker reads: Anything without `@category` is collected into `typeSpec.json` (for cross-reference by `$ref`) but does **not** appear in the navigation/section list. +### Non-TypeDoc sources + +#### Dart/Flutter + +Not every SDK ships TypeDoc. Dart is adapted "as a pre-step": the committed +legacy spec [`spec/supabase_dart_v2.yml`](../supabase_dart_v2.yml) plus the +shared section tree (`spec/common-client-libs-sections.json`) are converted into +a TypeDoc-shaped dump by +[`scripts/generate-dart-reference.ts`](../../scripts/generate-dart-reference.ts). +Each Dart method becomes a `variant: 'declaration'` node tagged with +`@category` / `@subcategory` (so it groups like any TypeDoc declaration) and +carries the legacy function shape (description, notes, params, examples) on a +non-TypeDoc `content` field. `build-reference-content.ts` spreads that `content` +straight onto the functions.json entry, so the renderer shows params, examples, +and notes exactly as the YAML did, with no typeSpec round-trip. The `content` +field is absent for real TypeDoc dumps, so JavaScript output is unchanged. + +The generated dump (`spec/reference/dart/v2/supabase_flutter.json`) is gitignored +like every other dump; `pnpm codegen:references:new` regenerates it from the +committed YAML before the content build (and a `make generate.dart.v2` target +mirrors that for manual runs). Overview/header blocks (e.g. "Using filters", +"Auth MFA") and the top-level markdown sections live as hand-authored partials +under `spec/reference/dart/v2/partials/`, exactly like the JavaScript lib. + `$ref` values are constructed as `...`, following TypeDoc's module (kind 2), namespace (kind 4), class (kind 128) and interface (kind 256) nesting. The `index` module segment is stripped via @@ -361,10 +385,7 @@ to the constant in [`features/docs/Reference.constants.ts`](../../features/docs/Reference.constants.ts): ```ts -export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set([ - 'javascript-v2', - // 'dart-v2', ← uncomment when ready -]) +export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set(['javascript-v2', 'dart-v2']) ``` The same set drives every runtime read that depends on the new layout: @@ -392,8 +413,16 @@ The same set drives every runtime read that depends on the new layout: directly. Other libs still go through `ClientLibReferenceLoader` against their YAML. To migrate another lib, swap the same way once it's in `SUPPORTS_NEW_REFERENCE_PROCESS`. +- The public/LLMs markdown generator + [`internals/generate-reference-markdown.ts`](../../internals/generate-reference-markdown.ts) + keys each lib on its own `kind` (`sdk-legacy` reads + `features/docs/generated/..*.json`; `sdk-new` reads + `content/reference///*.json`). This switch is independent of + `SUPPORTS_NEW_REFERENCE_PROCESS`, so a migrated lib must also be flipped to + `sdk-new` here, otherwise it reads legacy files the generator above no longer + produces and the build fails. -No other call sites in the render path need to change. +No other call sites need to change. > ⚠️ Don't move the constant. `Reference.utils.ts` transitively pulls in > `next/navigation`, which crashes `tsx --conditions=react-server` (used by diff --git a/apps/docs/spec/reference/dart/v2/config.json b/apps/docs/spec/reference/dart/v2/config.json new file mode 100644 index 00000000000..6f94523553b --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/config.json @@ -0,0 +1,9 @@ +{ + "categoryOrder": ["Database", "Auth", "Edge Functions", "Realtime", "Storage"], + "partialsOrder": ["introduction", "installing", "initializing", "upgrade-guide"], + "navigationPrefixes": { + "Database": false, + "Realtime": false, + "Edge Functions": "functions" + } +} diff --git a/apps/docs/spec/reference/dart/v2/partials/auth-admin.json b/apps/docs/spec/reference/dart/v2/partials/auth-admin.json new file mode 100644 index 00000000000..8b83dec1d55 --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/auth-admin.json @@ -0,0 +1,13 @@ +{ + "id": "auth-admin", + "title": "Auth Admin", + "notes": "- Any method under the `supabase.auth.admin` namespace requires a `secret` key.\n- These methods are considered admin methods and should be called on a trusted server. Never expose your `secret` key in the Flutter app.\n", + "examples": [ + { + "id": "create-auth-admin-client", + "name": "Create server-side auth client", + "isSpotlight": true, + "code": "```dart\nfinal supabase = SupabaseClient(supabaseUrl, secretKey);\n```\n" + } + ] +} diff --git a/apps/docs/spec/reference/dart/v2/partials/auth-mfa.json b/apps/docs/spec/reference/dart/v2/partials/auth-mfa.json new file mode 100644 index 00000000000..0bd76132415 --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/auth-mfa.json @@ -0,0 +1,5 @@ +{ + "id": "auth-mfa", + "title": "Auth MFA", + "notes": "This section contains methods commonly used for Multi-Factor Authentication (MFA) and are invoked behind the `supabase.auth.mfa` namespace.\n\nCurrently, Supabase supports time-based one-time password (TOTP) and phone verification code as the 2nd factor. Recovery codes are not supported but users can enroll multiple factors, with an upper limit of 10..\n\nHaving a 2nd factor for recovery frees the user of the burden of having to store their recovery codes somewhere. It also reduces the attack surface since multiple recovery codes are usually generated compared to just having 1 backup factor.\n\nLearn more about implementing MFA on your application on our guide [here](https://supabase.com/docs/guides/auth/auth-mfa#overview).\n" +} diff --git a/apps/docs/spec/reference/dart/v2/partials/auth-passkey.json b/apps/docs/spec/reference/dart/v2/partials/auth-passkey.json new file mode 100644 index 00000000000..0ef7219b3e4 --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/auth-passkey.json @@ -0,0 +1,5 @@ +{ + "id": "auth-passkey", + "title": "Auth Passkey", + "notes": "This section contains methods for WebAuthn passkey registration, authentication, and management. Methods are invoked behind the `supabase.auth.passkey` namespace.\n\nThese methods expose the server side of the WebAuthn ceremony. The client side (the FaceID/TouchID/security key prompt) has to be performed with a platform passkey API: `navigator.credentials.create()`/`get()` on web, or a passkey plugin on iOS/Android/macOS. Options and credentials are exchanged as `Map` in the W3C WebAuthn Level 3 JSON format.\n\nFor a one-call alternative that runs the full ceremony, see [`signInWithPasskey()`](/docs/reference/dart/auth-signinwithpasskey) and [`registerPasskey()`](/docs/reference/dart/auth-registerpasskey) on `supabase_flutter`.\n\nPasskey support is a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys.\n" +} diff --git a/apps/docs/spec/reference/dart/v2/partials/file-buckets.json b/apps/docs/spec/reference/dart/v2/partials/file-buckets.json new file mode 100644 index 00000000000..ce5d2ef6ace --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/file-buckets.json @@ -0,0 +1,5 @@ +{ + "id": "file-buckets", + "title": "File Buckets", + "notes": "This section contains methods for working with File Buckets.\n" +} diff --git a/apps/docs/spec/reference/dart/v2/partials/initializing.json b/apps/docs/spec/reference/dart/v2/partials/initializing.json new file mode 100644 index 00000000000..165a6b0c31c --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/initializing.json @@ -0,0 +1,117 @@ +{ + "id": "initializing", + "title": "Initializing", + "description": "You can initialize Supabase with the static `initialize()` method of the `Supabase` class.\n\nThe Supabase client is your entrypoint to the rest of the Supabase functionality\nand is the easiest way to interact with everything we offer within the Supabase ecosystem.\n", + "params": [ + { + "name": "url", + "isOptional": false, + "type": "string", + "description": "The unique Supabase URL which is supplied when you create a new project in your project dashboard." + }, + { + "name": "publishableKey", + "isOptional": false, + "type": "string", + "description": "The publishable (anon) key supplied when you create a new project in your project dashboard. Use this for client-side apps. The deprecated `anonKey` parameter is still accepted but `publishableKey` takes precedence when both are supplied." + }, + { + "name": "headers", + "isOptional": true, + "type": "Map", + "description": "Custom header to be passed to the Supabase client." + }, + { + "name": "httpClient", + "isOptional": true, + "type": "Client", + "description": "Custom http client to be used by the Supabase client." + }, + { + "name": "authOptions", + "isOptional": true, + "type": "FlutterAuthClientOptions", + "description": "Options to change the Auth behaviors.", + "subContent": [ + { + "name": "authFlowType", + "isOptional": true, + "type": "AuthFlowType", + "description": "Whether to use the `pkce` flow or the `implicit` flow. Defaults to `pkce`." + }, + { + "name": "localStorage", + "isOptional": true, + "type": "LocalStorage", + "description": "Parameter to override the local storage to store auth tokens." + }, + { + "name": "autoRefreshToken", + "isOptional": true, + "type": "bool", + "description": "Whether to automatically refresh the token when it expires. Defaults to `true`." + } + ] + }, + { + "name": "postgrestOptions", + "isOptional": true, + "type": "PostgrestClientOptions", + "description": "Options to change the Postgrest behaviors.", + "subContent": [ + { + "name": "schema", + "isOptional": true, + "type": "String", + "description": "Schema to query with the Supabase client. Defaults to `public`." + } + ] + }, + { + "name": "realtimeClientOptions", + "isOptional": true, + "type": "RealtimeClientOptions", + "description": "Options to change the Realtime behaviors.", + "subContent": [ + { + "name": "logLevel", + "isOptional": true, + "type": "RealtimeLogLevel", + "description": "Level of realtime server logs to to be logged." + } + ] + }, + { + "name": "storageOptions", + "isOptional": true, + "type": "StorageClientOptions", + "description": "Options to change the Storage behaviors.", + "subContent": [ + { + "name": "retryAttempts", + "isOptional": true, + "type": "int", + "description": "The number of times to retry a failed upload request. Defaults to `0`." + }, + { + "name": "useNewHostname", + "isOptional": true, + "type": "bool", + "description": "Whether to rewrite legacy storage URLs to use the dedicated storage host (`.storage.supabase.co`). Set to `true` only if your project has the dedicated storage host enabled. Defaults to `false`." + } + ] + } + ], + "examples": [ + { + "id": "flutter-initialize", + "name": "For Flutter", + "code": "```dart\nFuture main() async {\n await Supabase.initialize(\n url: 'https://xyzcompany.supabase.co',\n publishableKey: 'your-publishable-key',\n );\n\n runApp(MyApp());\n}\n\n// Get a reference your Supabase client\nfinal supabase = Supabase.instance.client;\n```\n" + }, + { + "id": "for-other-dart-projects", + "name": "For other Dart projects", + "code": "```dart\nfinal supabase = SupabaseClient(\n 'https://xyzcompany.supabase.co',\n 'your-secret-key', // use your secret key for server-side usage\n);\n```\n" + } + ] +} diff --git a/apps/docs/spec/reference/dart/v2/partials/installing.mdx b/apps/docs/spec/reference/dart/v2/partials/installing.mdx new file mode 100644 index 00000000000..d45311e3577 --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/installing.mdx @@ -0,0 +1,72 @@ +--- +title: 'Installing' +--- + +### Install from pub.dev + + + + + You can install Supabase package from [pub.dev](https://pub.dev/packages/supabase_flutter) + + + + + + + + + ```sh Terminal + flutter pub add supabase_flutter + ``` + + + + + ```sh Terminal + dart pub add supabase + ``` + + + + + + + +### Enable Data API access + + + + + supabase_flutter uses the Data API to query and mutate your Postgres data. You first need to grant Data API roles permissions to access your tables and functions. + + In [Data API integrations settings](/dashboard/project/_/integrations/data_api/settings), expose the specific tables and functions you want to access. To automatically grant access for new tables and functions in `public`, enable **Default privileges for new entities**. + + Alternatively, use SQL to grant the required permissions: + + + + + + ```sql + -- Before granting access to client roles, make sure RLS is enabled + -- and create the policies required for each role's allowed operations. + alter table public.your_table enable row level security; + -- create policy ... on public.your_table ...; + + -- Grant least-privilege access to tables after RLS and policies are in place + grant select on public.your_table to anon; + grant select, insert, update, delete on public.your_table to authenticated; + grant all on public.your_table to service_role; + + -- Grant execute on functions after verifying any table access they rely on + grant execute on function public.your_function to authenticated, service_role; + ``` + + + diff --git a/apps/docs/spec/reference/dart/v2/partials/introduction.mdx b/apps/docs/spec/reference/dart/v2/partials/introduction.mdx new file mode 100644 index 00000000000..a1b21c70f2b --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/introduction.mdx @@ -0,0 +1,7 @@ +--- +title: 'Introduction' +--- + +This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files. + +We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects. diff --git a/apps/docs/spec/reference/dart/v2/partials/passkey-admin.json b/apps/docs/spec/reference/dart/v2/partials/passkey-admin.json new file mode 100644 index 00000000000..da427fe193d --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/passkey-admin.json @@ -0,0 +1,5 @@ +{ + "id": "passkey-admin", + "title": "Passkey Admin", + "notes": "Contains passkey administration methods, accessed under the `supabase.auth.admin.passkey` namespace. Requires a `secret` key.\n\nPasskey support is a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys.\n" +} diff --git a/apps/docs/spec/reference/dart/v2/partials/upgrade-guide.mdx b/apps/docs/spec/reference/dart/v2/partials/upgrade-guide.mdx new file mode 100644 index 00000000000..07cb3ddcf54 --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/upgrade-guide.mdx @@ -0,0 +1,815 @@ +--- +title: 'Upgrade guide' +--- + +Although `supabase_flutter` v2 brings a few breaking changes, for the most part the public API should be the same with a few minor exceptions. +We have brought numerous updates behind the scenes to make the SDK work more intuitively for Flutter and Dart developers. + +## Upgrade the client library + + + + + Make sure you are using v2 of the client library in your `pubspec.yaml` file. + + + + + ```yaml + supabase_flutter: ^2.0.0 + ``` + + + + + + + + _Optionally_ passing custom configuration to `Supabase.initialize()` is now organized into separate objects: + + + + + + + + ```dart main.dart + await Supabase.initialize( + url: supabaseUrl, + publishableKey: publishableKey, + authFlowType: AuthFlowType.pkce, + storageRetryAttempts: 10, + realtimeClientOptions: const RealtimeClientOptions( + logLevel: RealtimeLogLevel.info, + ), + ); + ``` + + + + + ```dart main.dart + await Supabase.initialize( + url: 'SUPABASE_URL', + publishableKey: 'SUPABASE_PUBLISHABLE_KEY', + authOptions: const FlutterAuthClientOptions( + authFlowType: AuthFlowType.pkce, + ), + realtimeClientOptions: const RealtimeClientOptions( + logLevel: RealtimeLogLevel.info, + ), + storageOptions: const StorageClientOptions( + retryAttempts: 10, + ), + ); + ``` + + + + + + + + +### Auth updates + + + + + #### Renaming Provider to OAuthProvider + + `Provider` enum is renamed to `OAuthProvider`. + Previously the `Provider` symbol often collided with classes in the [provider](https://pub.dev/packages/provider) package and developers needed to add import prefixes to avoid collisions. + With the new update, developers can use Supabase and Provider in the same codebase without any import prefixes. + + + + + + + ```dart + await supabase.auth.signInWithOAuth( + Provider.google, + ); + ``` + + + + ```dart + await supabase.auth.signInWithOAuth( + OAuthProvider.google, + ); + ``` + + + + + + + + + + + #### Sign in with Apple method deprecated + + We have removed the [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) dependency in v2. + This is because not every developer needs to sign in with Apple, and we want to reduce the number of dependencies in the library. + + With v2, you can import [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) as a separate dependency if you need to sign in with Apple. + We have also added `auth.generateRawNonce()` method to easily generate a secure nonce. + + + + + + ```dart + await supabase.auth.signInWithApple(); + ``` + + + + ```dart + Future signInWithApple() async { + final rawNonce = supabase.auth.generateRawNonce(); + final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); + + final credential = await SignInWithApple.getAppleIDCredential( + scopes: [ + AppleIDAuthorizationScopes.email, + AppleIDAuthorizationScopes.fullName, + ], + nonce: hashedNonce, + ); + + final idToken = credential.identityToken; + if (idToken == null) { + throw const AuthException( + 'Could not find ID Token from generated credential.', + ); + } + + return signInWithIdToken( + provider: OAuthProvider.apple, + idToken: idToken, + nonce: rawNonce, + ); + } + ``` + + + + + + + + + + + #### Initialization does not await for session refresh + + In v1, `Supabase.initialize()` would await for the session to be refreshed before returning. + This caused delays in the app's launch time, especially when the app is opened in a poor network environment. + + In v2, `Supabase.initialize()` returns immediately after obtaining the session from the local storage, which makes the app launch faster. + Because of this, there is no guarantee that the session is valid when the app starts. + + If you need to make sure the session is valid, you can access the `isExpired` getter to check if the session is valid. + If the session is expired, you can listen to the `onAuthStateChange` event and wait for a new `tokenRefreshed` event to be fired. + + + + + + ```dart + // Session is valid, no check required + final session = supabase.auth.currentSession; + ``` + + + + ```dart + final session = supabase.auth.currentSession; + + // Check if the session is valid. + final isSessionExpired = session?.isExpired; + ``` + + + + + + + + + + + + #### Removing Flutter Webview dependency for OAuth sign in + + In v1, on iOS you could pass a `BuildContext` to the `signInWithOAuth()` method to launch the OAuth flow in a Flutter Webview. + + In v2, we have dropped the [webview_flutter](https://pub.dev/packages/webview_flutter) dependency in v2 to allow you to have full control over the UI of the OAuth flow. + We now have [native support for Google and Apple sign in](/docs/reference/dart/auth-signinwithidtoken), so opening an external browser is no longer needed on iOS. + + Because of this update, we no longer need the `context` parameter, so we have removed the `context` parameter from the `signInWithOAuth()` method. + + + + + + + ```dart + // Opens a webview on iOS. + await supabase.auth.signInWithOAuth( + Provider.github, + authScreenLaunchMode: LaunchMode.inAppWebView, + context: context, + ); + ``` + + + + ```dart + // Opens in app webview on iOS. + await supabase.auth.signInWithOAuth( + OAuthProvider.github, + authScreenLaunchMode: LaunchMode.inAppWebView, + ); + ``` + + + + + + + + + + + #### PKCE is the default auth flow type + + [PKCE flow](https://supabase.com/blog/supabase-auth-sso-pkce#introducing-pkce), which is a more secure method for obtaining sessions from deep links, is now the default auth flow for any authentication involving deep links. + + + + + + ```dart + await Supabase.initialize( + url: 'SUPABASE_URL', + publishableKey: 'SUPABASE_PUBLISHABLE_KEY', + authFlowType: AuthFlowType.implicit, // set to implicit by default + ); + ``` + + + + ```dart + await Supabase.initialize( + url: 'SUPABASE_URL', + publishableKey: 'SUPABASE_PUBLISHABLE_KEY', + authOptions: FlutterAuthClientOptions( + authFlowType: AuthFlowType.pkce, // set to pkce by default + ) + ); + ``` + + + + + + + + + + + #### Auth callback host name parameter removed + + `Supabase.initialize()` no longer has the `authCallbackUrlHostname` parameter. + The `supabase_flutter` SDK will automatically detect auth callback URLs and handle them internally. + + + + + + ```dart + await Supabase.initialize( + url: 'SUPABASE_URL', + publishableKey: 'SUPABASE_PUBLISHABLE_KEY', + authCallbackUrlHostname: 'auth-callback', + ); + ``` + + + + ```dart + await Supabase.initialize( + url: 'SUPABASE_URL', + publishableKey: 'SUPABASE_PUBLISHABLE_KEY', + ); + ``` + + + + + + + + + + + #### SupabaseAuth class removed + + The `SupabaseAuth` had an `initialSession` member, which was used to obtain the initial session upon app start. + This is now removed, and `currentSession` should be used to access the session at any time. + + + + + + ```dart + // Use `initialSession` to obtain the initial session when the app starts. + final initialSession = await SupabaseAuth.initialSession; + ``` + + + + ```dart + // Use `currentSession` to access the session at any time. + final initialSession = await supabase.auth.currentSession; + ``` + + + + + + + +### Data methods + + + + + #### Insert and return data + + We made the query builder immutable, which means you can reuse the same query object to chain multiple filters and get the expected outcome. + + + + + + + ```dart + // If you declare a query and chain filters on it + final myQuery = supabase.from('my_table').select(); + + final foo = await myQuery.eq('some_col', 'foo'); + + // The `eq` filter above is applied in addition to the following filter + final bar = await myQuery.eq('another_col', 'bar'); + ``` + + + + ```dart + // Now you can declare a query and reuse it. + final myQuery = supabase.from('my_table').select(); + + final foo = await myQuery.eq('some_col', 'foo'); + + // The `eq` filter above is not applied to the following result + final bar = await myQuery.eq('another_col', 'bar'); + ``` + + + + + + + + + + + #### Renaming is and in filter + + Because `is` and `in` are [reserved keywords](https://dart.dev/languages/keywords) in Dart, v1 used `is_` and `in_` as query filter names. + Users found the underscore confusing, so the query filters are now renamed to `isFilter` and `inFilter`. + + + + + + + ```dart + final data = await supabase + .from('users') + .select() + .is_('status', null); + + final data = await supabase + .from('users') + .select() + .in_('status', ['ONLINE', 'OFFLINE']); + ``` + + + + ```dart + final data = await supabase + .from('users') + .select() + .isFilter('status', null); + + final data = await supabase + .from('users') + .select() + .inFilter('status', ['ONLINE', 'OFFLINE']); + ``` + + + + + + + + + + + #### Deprecate FetchOption in favor of `count()` and `head()` methods + + `FetchOption()` on `.select()` is now deprecated, and new `.count()` and `head()` methods are added to the query builder. + + `count()` on `.select()` performs the select while also getting the count value, and `.count()` directly on `.from()` performs a head request resulting in only fetching the count value. + + + + + + + ```dart + // Request with count option + final res = await supabase.from('cities').select( + 'name', + const FetchOptions( + count: CountOption.exact, + ), + ); + + final data = res.data; + final count = res.count; + + // Request with count and head option + // obtains the count value without fetching the data. + final res = await supabase.from('cities').select( + 'name', + const FetchOptions( + count: CountOption.exact, + head: true, + ), + ); + + final count = res.count; + ``` + + + + ```dart + // Request with count option + final res = await supabase + .from('cities') + .select('name') + .count(); // CountOption.exact is the default value + + final data = res.data; + final int count = res.count; + + // `.count()` directly on `.from()` performs a head request, + // obtaining the count value without fetching the data. + final int count = await supabase + .from('cities') + .count(); // CountOption.exact is the default value + ``` + + + + + + + + + + + #### PostgREST error codes + + The `PostgrestException` instance thrown by the API methods has a `code` property. In v1, the `code` property contained the http status code. + + In v2, the `code` property contains the [PostgREST error code](https://postgrest.org/en/stable/references/errors.html), which is more useful for debugging. + + + + + + + ```dart + try { + await supabase.from('countries').select(); + } on PostgrestException catch (error) { + error.code; // Contains http status code + } + ``` + + + + ```dart + try { + await supabase.from('countries').select(); + } on PostgrestException catch (error) { + error.code; // Contains PostgREST error code + } + ``` + + + + + + + + ### Realtime methods + + Realtime methods contains the biggest breaking changes. Most of these changes are to make the interface more type safe. + + We have removed the `.on()` method and replaced it with `.onPostgresChanges()`, `.onBroadcast()`, and three different presence methods. + + + + + #### Postgres Changes + + Use the new `.onPostgresChanges()` method to listen to realtime changes in the database. + + In v1, filters were not strongly typed because they took a `String` type. In v2, `filter` takes an object. Its properties are strictly typed to catch type errors. + + The payload of the callback is now typed as well. In `v1`, the payload was returned as `dynamic`. It is now returned as a `PostgresChangePayload` object. The object contains the `oldRecord` and `newRecord` properties for accessing the data before and after the change. + + + + + + + + ```dart + supabase.channel('my_channel').on( + RealtimeListenTypes.postgresChanges, + ChannelFilter( + event: '*', + schema: 'public', + table: 'messages', + filter: 'room_id=eq.200', + ), + (dynamic payload, [ref]) { + final Map newRecord = payload['new']; + final Map oldRecord = payload['old']; + }, + ).subscribe(); + ``` + + + + + ```dart + supabase.channel('my_channel') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'messages', + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'room_id', + value: 200, + ), + callback: (PostgresChangePayload payload) { + final Map newRecord = payload.newRecord; + final Map oldRecord = payload.oldRecord; + }) + .subscribe(); + ``` + + + + + + + + + + + #### Broadcast + + Broadcast now uses the dedicated `.onBroadcast()` method, rather than the generic `.on()` method. + Because the method is specific to broadcast, it takes fewer properties. + + + + + + + + ```dart + supabase.channel('my_channel').on( + RealtimeListenTypes.broadcast, + ChannelFilter( + event: 'position', + ), + (dynamic payload, [ref]) { + print(payload); + }, + ).subscribe(); + ``` + + + + + ```dart + supabase + .channel('my_channel') + .onBroadcast( + event: 'position', + callback: (Map payload) { + print(payload); + }) + .subscribe(); + ``` + + + + + + + + + + + #### Presence + + Realtime Presence gets three different methods for listening to three different presence events: `sync`, `join`, and `leave`. + This allows the callback to be strictly typed. + + + + + + + + ```dart + final channel = supabase.channel('room1'); + + channel.on( + RealtimeListenTypes.presence, + ChannelFilter(event: 'sync'), + (payload, [ref]) { + print('Synced presence state: ${channel.presenceState()}'); + }, + ).on( + RealtimeListenTypes.presence, + ChannelFilter(event: 'join'), + (payload, [ref]) { + print('Newly joined presences $payload'); + }, + ).on( + RealtimeListenTypes.presence, + ChannelFilter(event: 'leave'), + (payload, [ref]) { + print('Newly left presences: $payload'); + }, + ).subscribe( + (status, [error]) async { + if (status == 'SUBSCRIBED') { + await channel.track({'online_at': DateTime.now().toIso8601String()}); + } + }, + ); + ``` + + + + + ```dart + final channel = supabase.channel('room1'); + + channel.onPresenceSync( + (payload) { + print('Synced presence state: ${channel.presenceState()}'); + }, + ).onPresenceJoin( + (payload) { + print('Newly joined presences $payload'); + }, + ).onPresenceLeave( + (payload) { + print('Newly left presences: $payload'); + }, + ).subscribe( + (status, error) async { + if (status == RealtimeSubscribeStatus.subscribed) { + await channel + .track({'online_at': DateTime.now().toIso8601String()}); + } + }, + ); + ``` + + + + + + diff --git a/apps/docs/spec/reference/dart/v2/partials/using-filters.json b/apps/docs/spec/reference/dart/v2/partials/using-filters.json new file mode 100644 index 00000000000..83a61c9af66 --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/using-filters.json @@ -0,0 +1,44 @@ +{ + "id": "using-filters", + "title": "Using filters", + "description": "Filters allow you to only return rows that match certain conditions.\n\nFilters can be used on `select()`, `update()`, `upsert()`, and `delete()` queries.\n\nIf a Database function returns a table response, you can also apply filters.\n", + "examples": [ + { + "id": "applying-filters", + "name": "Applying Filters", + "description": "Filters must be applied after any of `select()`, `update()`, `upsert()`,\n`delete()`, and `rpc()` and before\n[modifiers](/docs/reference/dart/using-modifiers).\n", + "code": "```dart\nfinal data = await supabase\n .from('cities')\n .select('name, country_id')\n .eq('name', 'The Shire'); // Correct\n\nfinal data = await supabase\n .from('cities')\n .eq('name', 'The Shire') // Incorrect\n .select('name, country_id');\n```\n" + }, + { + "id": "chaining-filters", + "name": "Chaining Filters", + "description": "Filters can be chained together to produce advanced queries as shown in the example code.\n", + "code": "```dart\nfinal data = await supabase\n .from('cities')\n .select('name, country_id')\n .gte('population', 1000)\n .lt('population', 10000)\n```\n" + }, + { + "id": "conditional-chaining", + "name": "Conditional Chaining", + "description": "Filters can be built up one step at a time and then executed as shown in the example code.\n", + "code": "```dart\nfinal filterByName = null;\nfinal filterPopLow = 1000;\nfinal filterPopHigh = 10000;\n\nvar query = supabase\n .from('cities')\n .select('name, country_id');\n\nif (filterByName != null) query = query.eq('name', filterByName);\nif (filterPopLow != null) query = query.gte('population', filterPopLow);\nif (filterPopHigh != null) query = query.lt('population', filterPopHigh);\n\nfinal data = await query;\n```\n" + }, + { + "id": "filter-by-value-within-json-column", + "name": "Filter by values within a JSON column", + "data": { + "sql": "```sql\ncreate table\n users (\n id int8 primary key,\n name text,\n address jsonb\n );\n\ninsert into\n users (id, name, address)\nvalues\n (1, 'Michael', '{ \"postcode\": 90210 }'),\n (2, 'Jane', null);\n```\n" + }, + "response": "```json\n[\n {\n 'id': 1,\n 'name': 'Michael',\n 'address': {\n 'postcode': 90210\n }\n },\n]\n```\n", + "code": "```dart\nfinal data = await supabase\n .from('users')\n .select()\n .eq('address->postcode', 90210);\n```\n" + }, + { + "id": "filter-referenced-tables", + "name": "Filter Referenced Tables", + "code": "```dart\nfinal data = await supabase\n .from('orchestral_sections')\n .select('''\n name,\n instruments!inner (\n name\n )\n ''')\n .eq('instruments.name', 'flute');\n```\n", + "data": { + "sql": "```sql\ncreate table\n orchestral_sections (id int8 primary key, name text);\ncreate table\n instruments (\n id int8 primary key,\n section_id int8 not null references orchestral_sections,\n name text\n );\n\ninsert into\n orchestral_sections (id, name)\nvalues\n (1, 'strings'),\n (2, 'woodwinds');\ninsert into\n instruments (id, section_id, name)\nvalues\n (1, 2, 'flute'),\n (2, 1, 'violin');\n```\n" + }, + "response": "```json\n[\n {\n 'name': 'strings',\n 'instruments': [\n {\n 'name': 'flute'\n }\n ]\n },\n]\n```\n", + "description": "You can filter on referenced tables in your `select()` query using dot\nnotation.\n" + } + ] +} diff --git a/apps/docs/spec/reference/dart/v2/partials/using-modifiers.json b/apps/docs/spec/reference/dart/v2/partials/using-modifiers.json new file mode 100644 index 00000000000..834f82fcf48 --- /dev/null +++ b/apps/docs/spec/reference/dart/v2/partials/using-modifiers.json @@ -0,0 +1,5 @@ +{ + "id": "using-modifiers", + "title": "Using modifiers", + "description": "Filters work on the row level. That is, they allow you to return rows that\nonly match certain conditions without changing the shape of the rows.\nModifiers are everything that don't fit that definition—allowing you to\nchange the format of the response (e.g., returning a CSV string).\n\nModifiers must be specified after filters. Some modifiers only apply for\nqueries that return rows (e.g., `select()` or `rpc()` on a function that\nreturns a table response).\n" +} diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 4371c692617..b1592131130 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -888,12 +888,17 @@ functions: notes: | Signs the user in with a passkey (WebAuthn). - Available on `supabase_flutter` 2.15.0 and later as an extension on `GoTrueClient`. - - Drives the full WebAuthn ceremony end to end: starts the challenge with the Supabase server, prompts the user for biometrics or a security key via the platform passkey API, and verifies the credential with the server. + - Drives the full WebAuthn ceremony end to end: starts the challenge with the Supabase server, calls the `authenticator` you supply to prompt the user for biometrics or a security key, and verifies the credential with the server. - Does not require an existing session. On success the session is persisted and an `AuthChangeEvent.signedIn` event is fired. + - `supabase_flutter` does not depend on a passkey plugin directly. Pass an implementation of `PasskeyAuthenticatorInterface`, such as the [`passkeys`](https://pub.dev/packages/passkeys) plugin's `PasskeyAuthenticator` (since `passkeys` `2.21.0`). - For native flows or custom UI, use the lower-level [`auth.passkey`](/docs/reference/dart/auth-passkey-api) namespace instead. - Passkeys are a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys. - Platform setup the library cannot perform (Associated Domains on iOS/macOS, Digital Asset Links on Android, the `passkeys` web SDK on web) is documented in the `supabase_flutter` package README. params: + - name: authenticator + isOptional: false + type: PasskeyAuthenticatorInterface + description: Performs the platform passkey ceremony (FaceID/TouchID/security key). For example, a `PasskeyAuthenticator` from the `passkeys` package. - name: captchaToken isOptional: true type: String @@ -904,7 +909,11 @@ functions: isSpotlight: true code: | ```dart - final AuthResponse res = await supabase.auth.signInWithPasskey(); + import 'package:passkeys/authenticator.dart'; + + final authenticator = PasskeyAuthenticator(); + + final AuthResponse res = await supabase.auth.signInWithPasskey(authenticator); final Session? session = res.session; final User? user = res.user; ``` @@ -913,17 +922,27 @@ functions: notes: | Registers a new passkey (WebAuthn credential) for the signed in user. - Available on `supabase_flutter` 2.15.0 and later as an extension on `GoTrueClient`. - - Drives the full WebAuthn ceremony end to end: starts the registration with the Supabase server, prompts the user to create a credential on the device, and verifies it with the server. + - Drives the full WebAuthn ceremony end to end: starts the registration with the Supabase server, calls the `authenticator` you supply to create a credential on the device, and verifies it with the server. - Requires a signed in (non-anonymous) user. If the user has verified MFA factors, the session has to be at `aal2` to manage passkeys. + - `supabase_flutter` does not depend on a passkey plugin directly. Pass an implementation of `PasskeyAuthenticatorInterface`, such as the [`passkeys`](https://pub.dev/packages/passkeys) plugin's `PasskeyAuthenticator` (since `passkeys` `2.21.0`). - For native flows or custom UI, use the lower-level [`auth.passkey`](/docs/reference/dart/auth-passkey-api) namespace instead. - Passkeys are a BETA feature and must be enabled for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys. + params: + - name: authenticator + isOptional: false + type: PasskeyAuthenticatorInterface + description: Performs the platform passkey ceremony (FaceID/TouchID/security key). For example, a `PasskeyAuthenticator` from the `passkeys` package. examples: - id: register-passkey name: Register a passkey for the current user isSpotlight: true code: | ```dart - final Passkey passkey = await supabase.auth.registerPasskey(); + import 'package:passkeys/authenticator.dart'; + + final authenticator = PasskeyAuthenticator(); + + final Passkey passkey = await supabase.auth.registerPasskey(authenticator); ``` - id: sign-out title: 'signOut()' diff --git a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json index a52a6d0f5ef..7b5acefc63c 100644 --- a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json +++ b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json @@ -5445,11 +5445,7 @@ }, "unavailableReason": { "type": "string", - "enum": [ - "manual_migration_required", - "postgres_upgrade_required", - "temporarily_unavailable" - ] + "enum": ["postgres_upgrade_required", "temporarily_unavailable"] } }, "required": ["state", "unavailableReason"] @@ -5560,11 +5556,7 @@ }, "unavailableReason": { "type": "string", - "enum": [ - "manual_migration_required", - "postgres_upgrade_required", - "temporarily_unavailable" - ] + "enum": ["postgres_upgrade_required", "temporarily_unavailable"] } }, "required": ["state", "unavailableReason"] @@ -14949,7 +14941,159 @@ }, "/v1/projects/{ref}/analytics/endpoints/logs.all": { "get": { - "description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. \n", + "deprecated": true, + "description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources.\n", + "operationId": "v1-get-project-logs-all", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "sql", + "required": false, + "in": "query", + "description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.", + "schema": { + "type": "string" + } + }, + { + "name": "iso_timestamp_start", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "example": "2025-03-01T00:00:00Z", + "type": "string" + } + }, + { + "name": "iso_timestamp_end", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "example": "2025-03-01T23:59:59Z", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": {} + }, + "error": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "location": { + "type": "string" + }, + "locationType": { + "type": "string" + }, + "message": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "domain", + "location", + "locationType", + "message", + "reason" + ] + } + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": ["code", "errors", "message", "status"] + } + ] + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "Usage exceeded. Enable additional usage to continue querying" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["analytics_logs_read"] + } + ], + "summary": "Gets project's logs", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics:read", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-oauth-scope": "analytics:read" + } + }, + "/v1/projects/{ref}/analytics/endpoints/logs": { + "get": { + "deprecated": false, + "description": "Executes an SQL or LQL query on the project's unified logs stream.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nFilter by the `source` column to specify specific log sources, such as edge_logs, postgres_logs, etc.\n\nNote: SQL must be written in **ClickHouse SQL dialect**.\n", "operationId": "v1-get-project-logs", "parameters": [ { @@ -15067,6 +15211,9 @@ "401": { "description": "Unauthorized" }, + "402": { + "description": "Usage exceeded. Enable additional usage to continue querying" + }, "403": { "description": "Forbidden action" }, @@ -15082,7 +15229,7 @@ "fga_permissions": ["analytics_logs_read"] } ], - "summary": "Gets project's logs", + "summary": "Gets all project's logs in a single log stream", "tags": ["Analytics"], "x-badges": [ { @@ -16650,7 +16797,7 @@ } } }, - "required": ["user_id", "user_roles"] + "required": ["user_roles"] } } } @@ -16987,7 +17134,7 @@ } } }, - "required": ["user_id", "user_roles"] + "required": ["user_roles"] } } } @@ -17002,7 +17149,7 @@ "description": "Rate limit exceeded" }, "500": { - "description": "Failed to upsert database migration" + "description": "Failed to update JIT access" } }, "security": [ @@ -17048,62 +17195,155 @@ "items": { "type": "array", "items": { - "type": "object", - "properties": { - "user_id": { - "type": "string", - "format": "uuid" - }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { + "oneOf": [ + { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "primary_email": { + "type": "string", + "nullable": true + }, + "invite_id": { + "type": "null" + }, + "expires_at": { + "type": "null" + }, + "user_roles": { + "type": "array", + "items": { "type": "object", "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] } }, - "required": ["cidr"] + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } } }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": [ + "user_id", + "primary_email", + "invite_id", + "expires_at", + "user_roles" + ] + }, + { + "type": "object", + "properties": { + "user_id": { + "type": "null" + }, + "primary_email": { + "type": "string" + }, + "invite_id": { + "type": "string", + "format": "uuid" + }, + "expires_at": { + "type": "string" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] } }, - "required": ["cidr"] + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } } + }, + "branches_only": { + "type": "boolean" } - } - }, - "branches_only": { - "type": "boolean" + }, + "required": ["role"] } - }, - "required": ["role"] - } + } + }, + "required": [ + "user_id", + "primary_email", + "invite_id", + "expires_at", + "user_roles" + ] } - }, - "required": ["user_id", "user_roles"] + ] } } }, @@ -17138,6 +17378,388 @@ "x-endpoint-owners": ["security"] } }, + "/v1/projects/{ref}/database/jit/invite": { + "post": { + "description": "Invites the external user and sets initial roles that can be assumed and for how long", + "operationId": "v1-invite-external-jit-access", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "minLength": 1 + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["email", "roles"], + "example": { + "email": "external-user@somedomain.xyz", + "roles": [ + { + "role": "postgres", + "expires_at": 1740787200, + "allowed_networks": { + "allowed_cidrs": [ + { + "cidr": "203.0.113.0/24" + } + ] + }, + "branches_only": false + } + ] + } + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "invite_id": { + "type": "string", + "format": "uuid" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["email", "invite_id", "user_roles"] + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to invite external user" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["database_jit_write"] + } + ], + "summary": "Invites an external user to a database for JIT access", + "tags": ["Database"], + "x-endpoint-owners": ["security"] + } + }, + "/v1/projects/{ref}/database/jit/invite/accept": { + "post": { + "description": "Accepts the invitation to JIT database access", + "operationId": "v1-accept-invite-external-jit-access", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "minLength": 1 + }, + "token": { + "type": "string", + "minLength": 1 + } + }, + "required": ["email", "token"], + "example": { + "email": "external-user@somedomain.xyz", + "token": "" + } + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["user_roles"] + } + } + } + }, + "500": { + "description": "Failed to accept invitation" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Accepts invitation for JIT database access", + "tags": ["Database"], + "x-endpoint-owners": ["security"] + } + }, + "/v1/projects/{ref}/database/jit/invite/{invite_id}": { + "delete": { + "description": "Revokes and deletes the invitation", + "operationId": "v1-delete-invite-external-jit-access", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "invite_id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "example": "55555555-5555-4555-8555-555555555555", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to revoke invite for external user" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["database_jit_write"] + } + ], + "summary": "Deletes the invite for an external user to a database for JIT access", + "tags": ["Database"], + "x-endpoint-owners": ["security"] + } + }, "/v1/projects/{ref}/database/jit/{user_id}": { "delete": { "description": "Remove JIT mappings of a user, revoking all JIT database access", @@ -22379,9 +23001,13 @@ }, "mfa_enabled": { "type": "boolean" + }, + "avatar_url": { + "type": "string", + "nullable": true } }, - "required": ["user_id", "user_name", "role_name", "mfa_enabled"] + "required": ["user_id", "user_name", "role_name", "mfa_enabled", "avatar_url"] } } } @@ -24616,11 +25242,7 @@ }, "unavailableReason": { "type": "string", - "enum": [ - "manual_migration_required", - "postgres_upgrade_required", - "temporarily_unavailable" - ] + "enum": ["postgres_upgrade_required", "temporarily_unavailable"] } }, "required": ["state", "unavailableReason"] @@ -29191,7 +29813,7 @@ } } }, - "required": ["user_id", "user_roles"] + "required": ["user_roles"] }, "AuthorizeJitAccessBody": { "type": "object", @@ -29272,62 +29894,143 @@ "items": { "type": "array", "items": { - "type": "object", - "properties": { - "user_id": { - "type": "string", - "format": "uuid" - }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { + "oneOf": [ + { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "primary_email": { + "type": "string", + "nullable": true + }, + "invite_id": { + "type": "null" + }, + "expires_at": { + "type": "null" + }, + "user_roles": { + "type": "array", + "items": { "type": "object", "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] } }, - "required": ["cidr"] + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } } }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] + }, + { + "type": "object", + "properties": { + "user_id": { + "type": "null" + }, + "primary_email": { + "type": "string" + }, + "invite_id": { + "type": "string", + "format": "uuid" + }, + "expires_at": { + "type": "string" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] } }, - "required": ["cidr"] + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } } + }, + "branches_only": { + "type": "boolean" } - } - }, - "branches_only": { - "type": "boolean" + }, + "required": ["role"] } - }, - "required": ["role"] - } + } + }, + "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] } - }, - "required": ["user_id", "user_roles"] + ] } } }, @@ -29409,6 +30112,163 @@ ] } }, + "InviteExternalUserJitAccessBody": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "minLength": 1 + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["email", "roles"], + "example": { + "email": "external-user@somedomain.xyz", + "roles": [ + { + "role": "postgres", + "expires_at": 1740787200, + "allowed_networks": { + "allowed_cidrs": [ + { + "cidr": "203.0.113.0/24" + } + ] + }, + "branches_only": false + } + ] + } + }, + "InviteExternalUserJitResponse": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "invite_id": { + "type": "string", + "format": "uuid" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["email", "invite_id", "user_roles"] + }, + "AcceptInviteExternalUserJitAccessBody": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "minLength": 1 + }, + "token": { + "type": "string", + "minLength": 1 + } + }, + "required": ["email", "token"], + "example": { + "email": "external-user@somedomain.xyz", + "token": "" + } + }, "FunctionResponse": { "type": "object", "properties": { @@ -31725,9 +32585,13 @@ }, "mfa_enabled": { "type": "boolean" + }, + "avatar_url": { + "type": "string", + "nullable": true } }, - "required": ["user_id", "user_name", "role_name", "mfa_enabled"] + "required": ["user_id", "user_name", "role_name", "mfa_enabled", "avatar_url"] }, "V1OrganizationSlugResponse": { "type": "object", diff --git a/apps/docs/spec/transforms/api_v2_openapi_deparsed.json b/apps/docs/spec/transforms/api_v2_openapi_deparsed.json index 45e90da5d75..0dcc4cc1f37 100644 --- a/apps/docs/spec/transforms/api_v2_openapi_deparsed.json +++ b/apps/docs/spec/transforms/api_v2_openapi_deparsed.json @@ -1687,6 +1687,11 @@ "type": "boolean", "description": "Whether this member is a Single Sign-On user" }, + "avatar_url": { + "type": "string", + "nullable": true, + "description": "Member's avatar URL" + }, "roles": { "type": "array", "items": { @@ -1733,6 +1738,7 @@ "primary_email", "mfa_enabled", "is_sso_user", + "avatar_url", "roles" ] } @@ -2992,6 +2998,11 @@ "type": "boolean", "description": "Whether this member is a Single Sign-On user" }, + "avatar_url": { + "type": "string", + "nullable": true, + "description": "Member's avatar URL" + }, "roles": { "type": "array", "items": { @@ -3033,7 +3044,14 @@ "description": "Roles assigned to this member. Includes both org-level and project-scoped roles." } }, - "required": ["username", "primary_email", "mfa_enabled", "is_sso_user", "roles"] + "required": [ + "username", + "primary_email", + "mfa_enabled", + "is_sso_user", + "avatar_url", + "roles" + ] } }, "required": ["type", "id", "attributes"] diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css index 47b3026db95..b9cdc543d93 100644 --- a/apps/docs/styles/globals.css +++ b/apps/docs/styles/globals.css @@ -11,6 +11,29 @@ @source './../../../packages/ui/src/**/*.{tsx,ts,js}'; @source './../../../packages/ui-patterns/src/**/*.{tsx,ts,js}'; +@theme inline { + --font-sans: + var(--font-inter), Inter, Helvetica Neue, Helvetica, ui-sans-serif, system-ui, sans-serif; + --font-heading: var(--font-manrope, var(--font-sans)); + --font-mono: 'Source Code Pro', ui-monospace, Menlo, monospace; +} + +@theme { + --text-sm: 0.8125rem; + --text-base: 0.9375rem; + --text-lg: 1rem; + --text-xl: 1.125rem; + --text-2xl: 1.375rem; + --text-3xl: 1.75rem; + --text-4xl: 2.125rem; + --text-5xl: 2.875rem; + --text-6xl: 3.625rem; + --text-7xl: 4.375rem; + --text-8xl: 5.875rem; + --text-9xl: 7.875rem; + --font-weight-normal: 450; +} + @layer utilities { .prose--remove-p-margin p { margin: 0; @@ -23,28 +46,30 @@ ::before, ::backdrop, ::file-selector-button { - border-color: hsl(var(--border-default, currentColor)); + border-color: var(--border-default, currentColor); } -} -@font-face { - font-family: 'custom-font'; - src: - url(../../../packages/common/assets/fonts/CustomFont-Book.woff2) format('woff2'), - url(../../../packages/common/assets/fonts/CustomFont-Book.woff) format('woff'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'custom-font'; - src: - url(../../../packages/common/assets/fonts/CustomFont-Medium.woff2) format('woff2'), - url(../../../packages/common/assets/fonts/CustomFont-Medium.woff) format('woff'); - font-weight: 500; - font-style: normal; - font-display: swap; + code, + .code-content, + pre, + kbd, + samp, + .font-mono { + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + --text-4xl: 2.25rem; + --text-5xl: 3rem; + --text-6xl: 3.75rem; + --text-7xl: 4.5rem; + --text-8xl: 6rem; + --text-9xl: 8rem; + --font-weight-normal: 400; + } } :root { @@ -52,9 +77,17 @@ --header-height: 50px; } +html { + scroll-behavior: smooth; + font-weight: 500; +} + +html[data-theme*='dark'] { + font-weight: 450; +} + body { @apply bg-background text-foreground; - scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -64,14 +97,19 @@ body { color: #333 !important; } -/* a { */ -/* text-decoration: none !important; */ -/* } */ - -article h1 { - /* margin-bottom: 2rem !important; */ - /* font-size: 3rem !important; */ - /* font-weight: 400 !important; */ +h1:not(.font-mono), +h2:not(.font-mono), +h3:not(.font-mono), +h4:not(.font-mono), +h5:not(.font-mono), +h6:not(.font-mono), +.h1:not(.font-mono), +.h2:not(.font-mono), +.h3:not(.font-mono), +.h4:not(.font-mono), +.h5:not(.font-mono), +.h6:not(.font-mono) { + @apply font-heading font-semibold; } .thin-scrollbar { @@ -103,10 +141,36 @@ article h1 { @apply text-foreground-muted text-xs; } -.prose :where(p):not(:where([class~='not-prose'] *)) { +.prose :where(p):not(:where([class~='not-prose'], [class~='not-prose'] *)) { white-space: pre-line; } +.prose :where(p, a, code, pre):not(:where([class~='not-prose'], [class~='not-prose'] *)), +.prose :where(ol > li):not(:where([class~='not-prose'], [class~='not-prose'] *))::marker { + font-weight: inherit; +} + +.prose :where(h1, h2, h3, h4, h5, h6):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-heading font-semibold; +} + +.prose + :where(p, li, a, figcaption):not(:where([class~='not-prose'], [class~='not-prose'] *)):not( + :where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) * + ) { + @apply text-base leading-7; +} + +.prose :where(h1, .h1):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-4xl; +} +.prose :where(h2, .h2):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-2xl; +} +.prose :where(h3, .h3):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-xl leading-7; +} + code[class*='language-'], pre[class*='language-'] { text-shadow: none !important; @@ -136,7 +200,7 @@ pre[class*='language-'] { } .method-list-title-isChild { - font-family: var(--custom-font-size-xs); + font-family: var(--font-mono); & h5 { font-weight: 200; } @@ -335,20 +399,13 @@ th code { transition-timing-function: cubic-bezier(0.24, 0.25, 0.05, 1) !important; } [data-rmiz-modal-overlay='visible'] { - background-color: hsl(var(--background-default)) !important; + background-color: var(--background-default) !important; opacity: 0.8; } [data-rmiz-modal-img] { image-rendering: high-quality; } -.skip-link { - @apply sr-only; -} -.skip-link:focus { - @apply not-sr-only; -} - /* Code blocks need margin applied when in content container */ .prose :where(.shiki:not(.shiki-wrapper *), .shiki-wrapper) { margin-block: 2rem; @@ -374,16 +431,16 @@ th code { [data-theme='light'], .light { --code-token-keyword: #6b35dc; - --code-foreground: hsl(var(--foreground-light) / 1); + --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; --code-token-string: #f1a10d; --code-token-comment: #7e7e7e; - --code-token-parameter: hsl(var(--foreground-light) / 1); + --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; --code-token-string-expression: #f1a10d; - --code-token-punctuation: hsl(var(--foreground-light) / 1); - --code-token-link: hsl(var(--foreground-light) / 1); - --code-token-number: hsl(var(--foreground-light) / 1); + --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); + --code-token-link: oklch(from var(--foreground-light) l c h / 1); + --code-token-number: oklch(from var(--foreground-light) l c h / 1); --code-token-property: #15593b; --code-highlight-color: #1c1c1c; } diff --git a/apps/docs/turbo.jsonc b/apps/docs/turbo.jsonc index b025460e4ac..6ffd9e5257a 100644 --- a/apps/docs/turbo.jsonc +++ b/apps/docs/turbo.jsonc @@ -10,8 +10,14 @@ "inputs": ["spec/**"], "outputs": ["features/docs/generated/**"], }, + // Generates the guide/reference .md files and manifest.json under public/markdown/. + // Declaring outputs lets Turbo restore these artifacts on a cache hit; without it a + // cached run would skip the script and leave the generated markdown missing for `next build`. + "build:markdown": { + "outputs": ["public/markdown/**", "public/markdown/manifest.json"], + }, "build": { - "dependsOn": ["^build", "codegen:examples", "codegen:references"], + "dependsOn": ["^build", "codegen:examples", "codegen:references", "build:markdown"], "env": [ "ANALYZE", "NEXT_PUBLIC_SUPABASE_URL", diff --git a/apps/learn/components/side-navigation.tsx b/apps/learn/components/side-navigation.tsx index 0f4c80dae6a..3668830c1a3 100644 --- a/apps/learn/components/side-navigation.tsx +++ b/apps/learn/components/side-navigation.tsx @@ -4,7 +4,7 @@ import { logOut, useIsLoggedIn, useUser } from 'common' import { LogOut, Settings, UserIcon } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { AuthenticatedDropdownMenu, type menuItem } from 'ui-patterns' +import { AuthenticatedDropdownMenu, type menuItem } from 'ui-patterns/AuthenticatedDropdownMenu' import { CommandMenu } from './command-menu' import { ThemeSwitcherDropdown } from './theme-switcher-dropdown' diff --git a/apps/learn/components/theme-switcher-dropdown.tsx b/apps/learn/components/theme-switcher-dropdown.tsx index 9a53adadb1d..776909bb4f5 100644 --- a/apps/learn/components/theme-switcher-dropdown.tsx +++ b/apps/learn/components/theme-switcher-dropdown.tsx @@ -14,7 +14,6 @@ import { DropdownMenuTrigger, RadioGroup, singleThemes, - Theme, } from 'ui' const ThemeSwitcherDropdown = () => { @@ -75,7 +74,7 @@ const ThemeSwitcherDropdown = () => { value={theme} onValueChange={(themeValue) => setTheme(themeValue)} > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( {theme.name} diff --git a/apps/learn/styles/code-block-variables.css b/apps/learn/styles/code-block-variables.css index f1054071748..f12e15fb992 100644 --- a/apps/learn/styles/code-block-variables.css +++ b/apps/learn/styles/code-block-variables.css @@ -18,16 +18,16 @@ [data-theme='light'], .light { --code-token-keyword: #6b35dc; - --code-foreground: hsl(var(--foreground-light) / 1); + --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; --code-token-string: #f1a10d; --code-token-comment: #7e7e7e; - --code-token-parameter: hsl(var(--foreground-light) / 1); + --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; --code-token-string-expression: #f1a10d; - --code-token-punctuation: hsl(var(--foreground-light) / 1); - --code-token-link: hsl(var(--foreground-light) / 1); - --code-token-number: hsl(var(--foreground-light) / 1); + --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); + --code-token-link: oklch(from var(--foreground-light) l c h / 1); + --code-token-number: oklch(from var(--foreground-light) l c h / 1); --code-token-property: #15593b; --code-highlight-color: #1c1c1c; } diff --git a/apps/learn/styles/globals.css b/apps/learn/styles/globals.css index 84065d57ffa..1478d871f62 100644 --- a/apps/learn/styles/globals.css +++ b/apps/learn/styles/globals.css @@ -11,25 +11,25 @@ --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); --color-chart-1: hsl(var(--chart-1)); --color-chart-2: hsl(var(--chart-2)); --color-chart-3: hsl(var(--chart-3)); @@ -39,74 +39,74 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 224 71.4% 4.1%; - --card: 0 0% 100%; - --card-foreground: 224 71.4% 4.1%; - --popover: 0 0% 100%; - --popover-foreground: 224 71.4% 4.1%; - --primary: 220.9 39.3% 11%; - --primary-foreground: 210 20% 98%; - --secondary: 220 14.3% 95.9%; - --secondary-foreground: 220.9 39.3% 11%; - --muted: 220 14.3% 95.9%; - --muted-foreground: 220 8.9% 46.1%; - --accent: 220 14.3% 95.9%; - --accent-foreground: 220.9 39.3% 11%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 84.2% 60.2%; - --border: 220 13% 91%; - --input: 220 13% 91%; - --ring: 220 13% 70.8%; + --background: hsl(0 0% 100%); + --foreground: hsl(224 71.4% 4.1%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(224 71.4% 4.1%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(224 71.4% 4.1%); + --primary: hsl(220.9 39.3% 11%); + --primary-foreground: hsl(210 20% 98%); + --secondary: hsl(220 14.3% 95.9%); + --secondary-foreground: hsl(220.9 39.3% 11%); + --muted: hsl(220 14.3% 95.9%); + --muted-foreground: hsl(220 8.9% 46.1%); + --accent: hsl(220 14.3% 95.9%); + --accent-foreground: hsl(220.9 39.3% 11%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(0 84.2% 60.2%); + --border: hsl(220 13% 91%); + --input: hsl(220 13% 91%); + --ring: hsl(220 13% 70.8%); --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.625rem; - --sidebar: 210 20% 98.5%; - --sidebar-foreground: 224 71.4% 4.1%; - --sidebar-primary: 220.9 39.3% 11%; - --sidebar-primary-foreground: 210 20% 98%; - --sidebar-accent: 220 14.3% 95.9%; - --sidebar-accent-foreground: 220.9 39.3% 11%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 220 13% 70.8%; + --sidebar: hsl(210 20% 98.5%); + --sidebar-foreground: hsl(224 71.4% 4.1%); + --sidebar-primary: hsl(220.9 39.3% 11%); + --sidebar-primary-foreground: hsl(210 20% 98%); + --sidebar-accent: hsl(220 14.3% 95.9%); + --sidebar-accent-foreground: hsl(220.9 39.3% 11%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(220 13% 70.8%); } .dark { - --background: 224 71.4% 4.1%; - --foreground: 210 20% 98%; - --card: 224 71.4% 4.1%; - --card-foreground: 210 20% 98%; - --popover: 224 71.4% 4.1%; - --popover-foreground: 210 20% 98%; - --primary: 210 20% 98%; - --primary-foreground: 220.9 39.3% 11%; - --secondary: 215 27.9% 16.9%; - --secondary-foreground: 210 20% 98%; - --muted: 215 27.9% 16.9%; - --muted-foreground: 217.9 10.6% 64.9%; - --accent: 215 27.9% 16.9%; - --accent-foreground: 210 20% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 84.2% 60.2%; - --border: 215 27.9% 16.9%; - --input: 215 27.9% 16.9%; - --ring: 217.9 10.6% 55.6%; + --background: hsl(224 71.4% 4.1%); + --foreground: hsl(210 20% 98%); + --card: hsl(224 71.4% 4.1%); + --card-foreground: hsl(210 20% 98%); + --popover: hsl(224 71.4% 4.1%); + --popover-foreground: hsl(210 20% 98%); + --primary: hsl(210 20% 98%); + --primary-foreground: hsl(220.9 39.3% 11%); + --secondary: hsl(215 27.9% 16.9%); + --secondary-foreground: hsl(210 20% 98%); + --muted: hsl(215 27.9% 16.9%); + --muted-foreground: hsl(217.9 10.6% 64.9%); + --accent: hsl(215 27.9% 16.9%); + --accent-foreground: hsl(210 20% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(0 84.2% 60.2%); + --border: hsl(215 27.9% 16.9%); + --input: hsl(215 27.9% 16.9%); + --ring: hsl(217.9 10.6% 55.6%); --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; - --sidebar: 220.9 39.3% 11%; - --sidebar-foreground: 210 20% 98%; - --sidebar-primary: 220 70% 50%; - --sidebar-primary-foreground: 210 20% 98%; - --sidebar-accent: 215 27.9% 16.9%; - --sidebar-accent-foreground: 210 20% 98%; - --sidebar-border: 215 27.9% 16.9%; - --sidebar-ring: 220 13% 43.9%; + --sidebar: hsl(220.9 39.3% 11%); + --sidebar-foreground: hsl(210 20% 98%); + --sidebar-primary: hsl(220 70% 50%); + --sidebar-primary-foreground: hsl(210 20% 98%); + --sidebar-accent: hsl(215 27.9% 16.9%); + --sidebar-accent-foreground: hsl(210 20% 98%); + --sidebar-border: hsl(215 27.9% 16.9%); + --sidebar-ring: hsl(220 13% 43.9%); } } @@ -153,75 +153,75 @@ } .preview { - --background: 0 0% 100%; - --foreground: 0 0% 14.5%; - --card: 0 0% 100%; - --card-foreground: 0 0% 14.5%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 14.5%; - --primary: 0 0% 20.5%; - --primary-foreground: 0 0% 98.5%; - --secondary: 0 0% 97%; - --secondary-foreground: 0 0% 20.5%; - --muted: 0 0% 97%; - --muted-foreground: 0 0% 55.6%; - --accent: 0 0% 97%; - --accent-foreground: 0 0% 20.5%; - --destructive: 27.3 24.5% 57.7%; - --destructive-foreground: 27.3 24.5% 57.7%; - --border: 0 0% 92.2%; - --input: 0 0% 92.2%; - --ring: 0 0% 70.8%; + --background: hsl(0 0% 100%); + --foreground: hsl(0 0% 14.5%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(0 0% 14.5%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(0 0% 14.5%); + --primary: hsl(0 0% 20.5%); + --primary-foreground: hsl(0 0% 98.5%); + --secondary: hsl(0 0% 97%); + --secondary-foreground: hsl(0 0% 20.5%); + --muted: hsl(0 0% 97%); + --muted-foreground: hsl(0 0% 55.6%); + --accent: hsl(0 0% 97%); + --accent-foreground: hsl(0 0% 20.5%); + --destructive: hsl(27.3 24.5% 57.7%); + --destructive-foreground: hsl(27.3 24.5% 57.7%); + --border: hsl(0 0% 92.2%); + --input: hsl(0 0% 92.2%); + --ring: hsl(0 0% 70.8%); --chart-1: 41.1 22.2% 64.6%; --chart-2: 184.7 11.8% 60%; --chart-3: 227.4 7% 39.8%; --chart-4: 84.4 18.9% 82.8%; --chart-5: 70.1 18.8% 76.9%; --radius: 0.625rem; - --sidebar: 0 0% 98.5%; - --sidebar-foreground: 0 0% 14.5%; - --sidebar-primary: 0 0% 20.5%; - --sidebar-primary-foreground: 0 0% 98.5%; - --sidebar-accent: 0 0% 97%; - --sidebar-accent-foreground: 0 0% 20.5%; - --sidebar-border: 0 0% 92.2%; - --sidebar-ring: 0 0% 70.8%; + --sidebar: hsl(0 0% 98.5%); + --sidebar-foreground: hsl(0 0% 14.5%); + --sidebar-primary: hsl(0 0% 20.5%); + --sidebar-primary-foreground: hsl(0 0% 98.5%); + --sidebar-accent: hsl(0 0% 97%); + --sidebar-accent-foreground: hsl(0 0% 20.5%); + --sidebar-border: hsl(0 0% 92.2%); + --sidebar-ring: hsl(0 0% 70.8%); } [data-theme='dark'] .preview, [data-theme='classic-dark'] .preview { - --background: 0 0% 14.5%; - --foreground: 0 0% 98.5%; - --card: 0 0% 0%; - --card-foreground: 0 0% 98.5%; - --popover: 0 0% 14.5%; - --popover-foreground: 0 0% 98.5%; - --primary: 0 0% 98.5%; - --primary-foreground: 0 0% 20.5%; - --secondary: 0 0% 26.9%; - --secondary-foreground: 0 0% 98.5%; - --muted: 0 0% 26.9%; - --muted-foreground: 0 0% 70.8%; - --accent: 0 0% 26.9%; - --accent-foreground: 0 0% 98.5%; - --destructive: 25.7 14.1% 39.6%; - --destructive-foreground: 25.3 23.7% 63.7%; - --border: 0 0% 26.9%; - --input: 0 0% 26.9%; - --ring: 0 0% 55.6%; + --background: hsl(0 0% 14.5%); + --foreground: hsl(0 0% 98.5%); + --card: hsl(0 0% 0%); + --card-foreground: hsl(0 0% 98.5%); + --popover: hsl(0 0% 14.5%); + --popover-foreground: hsl(0 0% 98.5%); + --primary: hsl(0 0% 98.5%); + --primary-foreground: hsl(0 0% 20.5%); + --secondary: hsl(0 0% 26.9%); + --secondary-foreground: hsl(0 0% 98.5%); + --muted: hsl(0 0% 26.9%); + --muted-foreground: hsl(0 0% 70.8%); + --accent: hsl(0 0% 26.9%); + --accent-foreground: hsl(0 0% 98.5%); + --destructive: hsl(25.7 14.1% 39.6%); + --destructive-foreground: hsl(25.3 23.7% 63.7%); + --border: hsl(0 0% 26.9%); + --input: hsl(0 0% 26.9%); + --ring: hsl(0 0% 55.6%); --chart-1: 264.4 24.3% 48.8%; --chart-2: 162.5 17% 69.6%; --chart-3: 70.1 18.8% 76.9%; --chart-4: 303.9 26.5% 62.7%; --chart-5: 16.4 24.6% 64.5%; - --sidebar: 0 0% 20.5%; - --sidebar-foreground: 0 0% 98.5%; - --sidebar-primary: 264.4 24.3% 48.8%; - --sidebar-primary-foreground: 0 0% 98.5%; - --sidebar-accent: 0 0% 26.9%; - --sidebar-accent-foreground: 0 0% 98.5%; - --sidebar-border: 0 0% 26.9%; - --sidebar-ring: 0 0% 43.9%; + --sidebar: hsl(0 0% 20.5%); + --sidebar-foreground: hsl(0 0% 98.5%); + --sidebar-primary: hsl(264.4 24.3% 48.8%); + --sidebar-primary-foreground: hsl(0 0% 98.5%); + --sidebar-accent: hsl(0 0% 26.9%); + --sidebar-accent-foreground: hsl(0 0% 98.5%); + --sidebar-border: hsl(0 0% 26.9%); + --sidebar-ring: hsl(0 0% 43.9%); } .preview * { diff --git a/apps/lite-studio/styles/globals.css b/apps/lite-studio/styles/globals.css index 7450b023ad8..d468bbe9fef 100644 --- a/apps/lite-studio/styles/globals.css +++ b/apps/lite-studio/styles/globals.css @@ -38,6 +38,6 @@ body { ::before, ::backdrop, ::file-selector-button { - border-color: hsl(var(--border-default, currentColor)); + border-color: var(--border-default, currentColor); } } diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 7074c1000f7..e8b45f1f023 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -1,12 +1,12 @@ { "rules": { - "react-hooks/exhaustive-deps": 172, + "react-hooks/exhaustive-deps": 162, "import/no-anonymous-default-export": 57, "@tanstack/query/exhaustive-deps": 9, - "@typescript-eslint/no-explicit-any": 952, + "@typescript-eslint/no-explicit-any": 901, "no-restricted-imports": 0, - "no-restricted-exports": 235, - "react/no-unstable-nested-components": 41 + "no-restricted-exports": 221, + "react/no-unstable-nested-components": 39 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -22,7 +22,7 @@ "components/interfaces/Auth/PerformanceSettingsForm.tsx": 1, "components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx": 1, "components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx": 1, - "components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx": 2, + "components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx": 1, "components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx": 1, "components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx": 1, "components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx": 1, @@ -57,14 +57,12 @@ "components/interfaces/Reports/ReportBlock/ChartBlock.tsx": 1, "components/interfaces/Reports/ReportFilterBar.tsx": 1, "components/interfaces/Reports/Reports.tsx": 1, - "components/interfaces/Reports/useReportFilters.ts": 4, "components/interfaces/SQLEditor/AskAIWidget.tsx": 1, "components/interfaces/SQLEditor/InlineWidget.tsx": 2, "components/interfaces/SQLEditor/MonacoEditor.tsx": 1, "components/interfaces/SQLEditor/MoveQueryModal.tsx": 1, "components/interfaces/SQLEditor/OngoingQueriesPanel.tsx": 1, "components/interfaces/SQLEditor/SQLEditor.tsx": 2, - "components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx": 1, "components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx": 1, "components/interfaces/Settings/Addons/IPv4SidePanel.tsx": 1, "components/interfaces/Settings/Addons/PITRSidePanel.tsx": 1, @@ -97,17 +95,13 @@ "components/ui/CodeEditor/CodeEditor.tsx": 1, "components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx": 2, "components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx": 2, - "components/ui/DataTable/DataTableHeaderLayout.tsx": 1, "components/ui/DataTable/DataTableSideBarLayout.tsx": 4, - "components/ui/DataTable/DataTableToolbar.tsx": 1, "components/ui/DataTable/DataTableViewOptions.tsx": 1, "components/ui/DataTable/LiveButton.tsx": 1, "components/ui/DataTable/TimelineChart.tsx": 1, "components/ui/DatePicker/TimeSplitInput.tsx": 1, "components/ui/DateRangePicker.tsx": 1, - "components/ui/Error.tsx": 1, "components/ui/FilterPopover.tsx": 1, - "components/ui/ScrollGradient.tsx": 1, "data/__templates/resource-query.ts": 1, "data/__templates/resources-query.ts": 1, "data/reports/api-report-query.ts": 2, @@ -128,7 +122,7 @@ "pages/logout.tsx": 1, "pages/new/index.tsx": 1, "pages/organizations.tsx": 1, - "pages/project/[ref]/auth/policies.tsx": 2, + "pages/project/[ref]/database/policies.tsx": 2, "pages/project/[ref]/editor/[id].tsx": 1, "pages/project/[ref]/integrations/[id]/index.tsx": 1, "pages/project/[ref]/settings/jwt/legacy.tsx": 1, @@ -209,7 +203,6 @@ "@typescript-eslint/no-explicit-any": { "components/grid/SupabaseGrid.tsx": 1, "components/grid/SupabaseGrid.utils.ts": 2, - "components/grid/components/common/MonacoEditor.tsx": 3, "components/grid/components/editor/JsonEditor.tsx": 2, "components/grid/components/editor/SelectEditor.tsx": 1, "components/grid/components/editor/TextEditor.tsx": 1, @@ -239,7 +232,6 @@ "components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx": 1, "components/interfaces/Auth/Policies/PolicyEditor/index.tsx": 2, "components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx": 1, - "components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx": 1, "components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx": 2, "components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx": 1, "components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx": 1, @@ -265,7 +257,7 @@ "components/interfaces/Database/Privileges/PrivilegesTable.tsx": 1, "components/interfaces/Database/Publications/PublicationsList.tsx": 5, "components/interfaces/Database/Publications/PublicationsTableItem.tsx": 5, - "components/interfaces/Database/Replication/DisableExternalReplicationDialog.tsx": 1, + "components/interfaces/Database/Replication/DisablePipelinesDialog.tsx": 1, "components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx": 1, "components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx": 1, "components/interfaces/Docs/Description.tsx": 2, @@ -278,9 +270,7 @@ "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1, "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx": 3, "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx": 1, - "components/interfaces/Functions/FunctionsNav.tsx": 1, "components/interfaces/Home/ProjectList/ProjectList.tsx": 1, - "components/interfaces/Home/ServiceStatus.tsx": 1, "components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx": 1, "components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx": 2, "components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx": 2, @@ -301,7 +291,6 @@ "components/interfaces/Linter/LinterDataGrid.tsx": 4, "components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx": 3, "components/interfaces/Observability/ObservabilityOverview.tsx": 1, - "components/interfaces/Observability/ServiceHealthCard.tsx": 1, "components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx": 3, "components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils.ts": 2, "components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx": 2, @@ -329,7 +318,6 @@ "components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton.tsx": 1, "components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorDisabledState.tsx": 1, "components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts": 2, - "components/interfaces/QueryPerformance/QueryPerformanceChart.tsx": 3, "components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx": 3, "components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx": 1, "components/interfaces/Realtime/Inspector/Messages.types.ts": 1, @@ -347,7 +335,6 @@ "components/interfaces/Reports/v2/ReportChartUpsell.tsx": 1, "components/interfaces/Reports/v2/ReportChartV2.tsx": 1, "components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx": 1, - "components/interfaces/SQLEditor/MonacoEditor.tsx": 2, "components/interfaces/SQLEditor/MoveQueryModal.tsx": 2, "components/interfaces/SQLEditor/RenameQueryModal.tsx": 2, "components/interfaces/SQLEditor/SQLEditor.tsx": 4, @@ -368,9 +355,9 @@ "components/interfaces/Settings/Logs/LogTable.tsx": 1, "components/interfaces/Settings/Logs/Logs.constants.ts": 1, "components/interfaces/Settings/Logs/Logs.types.ts": 1, + "components/interfaces/Settings/Logs/Logs.utils.otel.ts": 1, "components/interfaces/Settings/Logs/Logs.utils.ts": 12, "components/interfaces/Settings/Logs/PreviewFilterPanel.tsx": 1, - "components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx": 2, "components/interfaces/SignIn/SignInForm.tsx": 1, "components/interfaces/SignIn/SignInWithCustom.tsx": 1, "components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.tsx": 2, @@ -422,18 +409,13 @@ "components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx": 2, "components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetPreviewGrid.tsx": 2, "components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx": 1, - "components/interfaces/TableGridEditor/TableDefinition.tsx": 3, "components/interfaces/TableGridEditor/TableEntity.utils.ts": 1, "components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx": 2, - "components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx": 4, - "components/interfaces/UnifiedLogs/ServiceFlow/config/fieldHelpers.ts": 3, "components/interfaces/UnifiedLogs/ServiceFlow/types.ts": 10, "components/interfaces/UnifiedLogs/ServiceFlow/utils/storageUtils.ts": 3, - "components/layouts/DocsLayout/DocsLayout.tsx": 2, "components/layouts/IntegrationsLayout/Integrations.utils.ts": 2, "components/layouts/Navigation/LayoutHeader/BreadcrumbsView.tsx": 2, "components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx": 3, - "components/layouts/Navigation/NavigationBar/NavigationIconLink.tsx": 1, "components/layouts/ProjectLayout/BuildingState.tsx": 1, "components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx": 2, "components/layouts/SQLEditorLayout/SQLEditorMenu.tsx": 2, @@ -443,7 +425,6 @@ "components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx": 1, "components/layouts/TableEditorLayout/EntityListItem.tsx": 1, "components/layouts/TableEditorLayout/TableEditorMenu.tsx": 1, - "components/to-be-cleaned/KeyMap.tsx": 3, "components/ui/AIAssistantPanel/AIAssistant.utils.ts": 1, "components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx": 1, "components/ui/AIAssistantPanel/MessageMarkdown.tsx": 1, @@ -451,14 +432,13 @@ "components/ui/CardButton.tsx": 2, "components/ui/Charts/AreaChart.tsx": 1, "components/ui/Charts/BarChart.tsx": 1, - "components/ui/Charts/ChartHandler.tsx": 4, "components/ui/Charts/ChartHeader.tsx": 5, "components/ui/Charts/Charts.utils.tsx": 3, "components/ui/Charts/ComposedChart.tsx": 8, "components/ui/Charts/ComposedChart.utils.tsx": 14, "components/ui/Charts/ComposedChartHandler.tsx": 9, "components/ui/Charts/StackedBarChart.tsx": 3, - "components/ui/CodeEditor/CodeEditor.tsx": 7, + "components/ui/CodeEditor/CodeEditor.tsx": 2, "components/ui/CodeEditor/CodeEditor.utils.ts": 2, "components/ui/CodeEditor/Providers/BackwardIterator.ts": 1, "components/ui/CodeEditor/Providers/PgSQLCompletionProvider.ts": 21, @@ -471,20 +451,15 @@ "components/ui/DatePicker/DatePicker.types.ts": 2, "components/ui/DatePicker/index.tsx": 2, "components/ui/DateRangePicker.tsx": 1, - "components/ui/DebouncedComponent.tsx": 1, "components/ui/DownloadResultsButton.tsx": 1, - "components/ui/Error.tsx": 1, "components/ui/ErrorBoundary/ErrorBoundary.tsx": 2, "components/ui/ErrorBoundary/GlobalErrorBoundaryState.tsx": 1, "components/ui/FilterPopover.tsx": 1, - "components/ui/Forms/Form.constants.ts": 1, "components/ui/ProductMenu/ProductMenu.types.ts": 1, "components/ui/ProjectSettings/DisplayApiSettings.tsx": 1, "components/ui/QueryBlock/QueryBlock.tsx": 4, "components/ui/QueryBlock/QueryBlock.utils.ts": 1, - "components/ui/SchemaComboBox.tsx": 1, "components/ui/SchemaSelector.tsx": 1, - "components/ui/SqlEditor.tsx": 6, "components/ui/ui.types.ts": 1, "data/ai/check-api-key-query.ts": 1, "data/ai/rate-message-mutation.ts": 1, @@ -492,8 +467,6 @@ "data/ai/sql-policy-mutation.ts": 1, "data/ai/sql-title-mutation.ts": 1, "data/analytics/functions-combined-stats-query.ts": 1, - "data/analytics/functions-req-stats-query.ts": 1, - "data/analytics/functions-resource-usage-query.ts": 1, "data/analytics/infra-monitoring-query.ts": 1, "data/analytics/project-daily-stats-queries.ts": 1, "data/auth/auth-overview-query.ts": 1, @@ -605,7 +578,7 @@ "pages/project/[ref]/settings/log-drains.tsx": 1, "state/ai-assistant-state.tsx": 4, "state/role-impersonation-state.tsx": 1, - "state/sql-editor-v2.ts": 5, + "state/sql-editor/sql-editor-state.ts": 4, "state/storage-explorer.tsx": 15, "state/table-editor-table.tsx": 15, "state/table-editor.tsx": 5, @@ -640,7 +613,6 @@ "components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx": 1, "components/interfaces/Billing/Payment/PaymentMethods/ChangePaymentMethodModal.tsx": 1, "components/interfaces/Billing/Payment/PaymentMethods/CreditCard.tsx": 1, - "components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx": 1, "components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx": 1, "components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx": 1, "components/interfaces/Billing/SpendCapModal.tsx": 1, @@ -652,7 +624,6 @@ "components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx": 1, - "components/interfaces/Database/Extensions/ExtensionCardSkeleton.tsx": 1, "components/interfaces/Database/Migrations/Migrations.tsx": 1, "components/interfaces/Database/Privileges/PrivilegesHead.tsx": 1, "components/interfaces/Database/Privileges/PrivilegesTable.tsx": 1, @@ -670,7 +641,6 @@ "components/interfaces/Functions/CommandRender.tsx": 1, "components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx": 1, "components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx": 1, - "components/interfaces/Functions/FunctionsNav.tsx": 1, "components/interfaces/Integrations/CronJobs/CronJobsEmptyState.tsx": 1, "components/interfaces/Integrations/CronJobs/CronSyntaxChart.tsx": 1, "components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx": 1, @@ -718,7 +688,6 @@ "components/interfaces/Reports/ReportWidget.tsx": 1, "components/interfaces/Reports/Reports.tsx": 1, "components/interfaces/SQLEditor/InlineWidget.tsx": 1, - "components/interfaces/SQLEditor/MonacoEditor.tsx": 1, "components/interfaces/SQLEditor/SQLTemplates/SQLExamples.tsx": 1, "components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx": 1, "components/interfaces/SQLEditor/UtilityPanel/ReadOnlyBadge.tsx": 1, @@ -750,7 +719,6 @@ "components/interfaces/Settings/Logs/LogColumnRenderers/MultigresColumnRender.tsx": 1, "components/interfaces/Settings/Logs/LogSelection.tsx": 1, "components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx": 1, - "components/interfaces/Settings/Logs/Logs.Divider.tsx": 1, "components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx": 1, "components/interfaces/Settings/Logs/Logs.SqlSnippetCode.tsx": 1, "components/interfaces/Settings/Logs/LogsErrorRenderers/ResourcesExceededErrorRenderer.tsx": 1, @@ -783,7 +751,6 @@ "components/layouts/BranchLayout/BranchLayout.tsx": 1, "components/layouts/DatabaseLayout/DatabaseLayout.tsx": 1, "components/layouts/DefaultLayout.tsx": 1, - "components/layouts/DocsLayout/DocsLayout.tsx": 1, "components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx": 1, "components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx": 1, "components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx": 1, @@ -791,8 +758,6 @@ "components/layouts/JWTKeys/JWTKeysLayout.tsx": 1, "components/layouts/LogsLayout/LogsLayout.tsx": 1, "components/layouts/Navigation/NavigationBar/MobileNavigationBar.tsx": 1, - "components/layouts/Navigation/NavigationBar/MobileViewNav.tsx": 1, - "components/layouts/Navigation/NavigationBar/NavigationIconLink.tsx": 1, "components/layouts/Navigation/ProductMenuBar.tsx": 1, "components/layouts/ObservabilityLayout/ObservabilityLayout.tsx": 1, "components/layouts/ObservabilityLayout/ObservabilityMenu.tsx": 1, @@ -805,7 +770,6 @@ "components/layouts/SQLEditorLayout/SQLEditorLayout.tsx": 1, "components/layouts/StorageLayout/StorageLayout.tsx": 1, "components/layouts/WizardLayout.tsx": 1, - "components/to-be-cleaned/KeyMap.tsx": 1, "components/to-be-cleaned/ProductEmptyState.tsx": 1, "components/to-be-cleaned/Table.tsx": 1, "components/ui/AIEditor/ResizableAIWidget.tsx": 1, @@ -813,10 +777,8 @@ "components/ui/CardButton.tsx": 1, "components/ui/Charts/AreaChart.tsx": 1, "components/ui/Charts/BarChart.tsx": 1, - "components/ui/Charts/ChartHandler.tsx": 1, "components/ui/Charts/NoDataPlaceholder.tsx": 1, "components/ui/Charts/StackedBarChart.tsx": 1, - "components/ui/CodeEditor/CodeEditor.tsx": 1, "components/ui/CodeEditor/Providers/BackwardIterator.ts": 1, "components/ui/CodeEditor/Providers/PgSQLCompletionProvider.ts": 1, "components/ui/CodeEditor/Providers/PgSQLSignatureHelpProvider.ts": 1, @@ -825,10 +787,8 @@ "components/ui/CountdownTimer/CountdownTimerSpan.tsx": 1, "components/ui/DateRangePicker.tsx": 1, "components/ui/EditorPanel/EditorPanel.tsx": 1, - "components/ui/Error.tsx": 1, "components/ui/FormBoxEmpty.tsx": 1, "components/ui/FunctionSelector.tsx": 1, - "components/ui/GrafanaPromoBanner.tsx": 1, "components/ui/InformationBox.tsx": 1, "components/ui/LoadingOpacity.tsx": 1, "components/ui/Logs/LogsExplorerHeader.tsx": 1, @@ -839,7 +799,6 @@ "components/ui/SchemaSelector.tsx": 1, "components/ui/ShimmerLine.tsx": 1, "components/ui/SparkBar.tsx": 1, - "components/ui/SqlEditor.tsx": 1, "data/prefetchers/PrefetchableLink.tsx": 1, "hooks/analytics/useDbQuery.tsx": 1, "hooks/analytics/useLogsPreview.tsx": 1, @@ -869,9 +828,7 @@ "components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx": 1, "components/interfaces/Settings/Logs/LogSelection.tsx": 1, "components/interfaces/Settings/Logs/LogsFormatters.tsx": 2, - "components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx": 1, "components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx": 1, - "components/layouts/Navigation/NavigationBar/NavigationIconLink.tsx": 1, "components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx": 3, "components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx": 1, "components/ui/AIAssistantPanel/MessageMarkdown.tsx": 1, diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore index 39577a49f19..55e8f54894a 100644 --- a/apps/studio/.gitignore +++ b/apps/studio/.gitignore @@ -15,6 +15,8 @@ next-env.d.ts # production /build +/dist +/.output # misc .DS_Store diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md new file mode 100644 index 00000000000..2d49fd90c13 --- /dev/null +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -0,0 +1,514 @@ +# TanStack Start migration — route checklist + +Temporary tracking doc. Delete once migration is done. + +**Runtime model — Next.js and TanStack Start run side-by-side** + +Throughout this migration both runtimes coexist in the same workspace: + +- The Next.js pages router (`pages/...`) and the TanStack route tree + (`routes/...`) ship **at the same time**. The Vite/TanStack build is + what we run today; the Next build (`build:next` / `dev:next` scripts + in `apps/studio/package.json`) stays alive as a fallback so we can + bisect regressions and ship either runtime if needed. +- **Do not delete any `apps/studio/pages/...` file during the per-route + migration.** Path A pages re-export their `pages/` default export, so + the Next file is load-bearing for both runtimes. Removing it breaks + the Next build and breaks the TanStack route too. +- Body-moves and `pages/...` deletion happen **only in the final + cleanup pass**, after every route is represented in `routes/...` and + we're ready to retire the Next runtime entirely. That's a separate, + deliberate phase — not something to fold into individual route PRs. +- Same rule for the Next compat shims (`apps/studio/compat/next/`): + they stay until the cleanup pass, regardless of how many routes have + moved. + +**Strategy — minimum-diff re-export** + +The goal is to flip URL ownership to TanStack without rewriting page internals yet. For each page we pick one of two paths: + +- **Path A — re-export from `pages/`** (default). The TanStack route imports the page's default export from `apps/studio/pages/...` and renders it inside a thin wrapper component used as the route's `component`. `getLayout` is dropped on the floor — the TanStack layout chain (pathless `_app.tsx` / `_auth.tsx` + sibling-file layouts) handles wrapping instead. The page's Next-specific imports keep working via the `compat/next/` shim. Because `NextPageWithLayout` declares `{ dehydratedState: any }` as required props, pass `dehydratedState={undefined}` in the wrapper. +- **Path B — direct component import**. When the `pages/...` file is essentially `export default SomeComponent` re-exporting a component from elsewhere (typical for thin page wrappers), skip the middle-man and import `SomeComponent` directly in the TanStack route. + +We still need to land the shared layouts up-front: + +- Pathless layout routes (`_app.tsx`, `_auth.tsx`) hold shared shells without contributing URL segments. +- Sibling-file layouts: `segment.tsx` next to a `segment/` directory provides the layout with `` for children in that directory (e.g. `_app/account.tsx` wraps `_app/account/me.tsx`). No `route.tsx` files. +- Each product layout (DatabaseLayout, AuthLayout, SQLEditorLayout, …) becomes one sibling-file layout. + +Once every page is represented in `routes/`, we do a second pass to properly move the page body into the route file and delete `pages/...`. Path-tracking (A vs B) below tells us which pages still have live Next files we need to eliminate. + +Other rules: + +- New code uses native TanStack APIs directly (no `next/router`, no `next/link`). The Next compat shim stays in place for pages we re-export. +- `withAuth()` HOC → TanStack `beforeLoad` on the containing route/layout. Apply this at shared-layout level where possible. +- **Never** delete a `pages/...` file mid-migration — both runtimes need to keep working. Body-moves and Next-file deletions are reserved for the cleanup pass at the very end, after every entry in this checklist is `[x]`. See "Runtime model" above. +- Not migrated via this list: `pages/api/**` (Next API routes — separate migration), `_app.tsx`, `_document.tsx`, `_error`, `pages/org/_/[[...routeSlug]].tsx`, `pages/project/_/[[...routeSlug]].tsx` (catch-alls — revisit at the end). + +**Legend** + +- `[ ]` not started +- `[~]` in progress +- `[x] A` done — re-exported from `pages/...` (Next file still exists, needs body-move later) +- `[x] A→done` done & body moved — Next file deleted +- `[x] B` done — direct component import, no Next file involved (or Next file already deletable) + +--- + +## Shared layouts + +These are the layout-only TanStack files. Most hold a single product layout component. + +### App shell (pathless) + +- [x] `routes/_app.tsx` — AppLayout + DefaultLayout (reads `defaultLayoutHeaderTitle`/`hideMobileMenu` from leaf `staticData`) +- [x] `routes/_app/account.tsx` — AccountLayout (reads `accountLayoutTitle` from leaf `staticData`) +- [x] `routes/_app/org.tsx` — OrganizationLayout (reads `orgLayoutTitle` from leaf `staticData`). **Delta vs plan:** placed at `_app/org.tsx` (wraps both `/org/` index and `/org/$slug/*`) instead of `_app/org/$slug.tsx`. PageLayout stays inline on `/org/$slug/index.tsx` since only that one route uses it. +- [x] `routes/_app/new.tsx` — skipped; only `_app/new/index.tsx` lives under \_app (inlines WizardLayout). `new/$slug` is top-level (no AppLayout) so a sub-shell would not actually share state. +- [x] `routes/integrations/vercel.tsx` — VercelIntegrationWindowLayout. **Delta vs plan:** placed at top-level rather than under `_app/` — Next getLayout for all three leaves wraps only in VercelIntegrationWindowLayout, no AppLayout/DefaultLayout. + +### Project shell + +- [x] `routes/project/$ref.tsx` — DefaultLayout only. **Delta vs plan:** ProjectLayoutWithAuth omitted from the shell because product layouts (DatabaseLayout, AuthLayout, StorageLayout, …) already render `withAuth(... ProjectLayout ...)` internally — adding it here would double-wrap. The home page (`/project/$ref/index.tsx`) wraps itself in `ProjectLayoutWithAuth` since it has no product layout. +- [x] `routes/project/$ref/database.tsx` — DatabaseLayout (reads `databaseLayoutTitle` from leaf `staticData`) +- [x] `routes/project/$ref/database/triggers.tsx` — sub-shell with `PageLayout` + permission gate + nav items, inlined from `DatabaseTriggersLayout`. **Delta vs plan:** the existing `DatabaseTriggersLayout` component wraps `` internally, so re-using it inside the database.tsx shell would double-wrap. Inlined the inner part instead; the Next-side component is left untouched (still used by the `pages/...` files we re-export). +- [x] `routes/project/$ref/auth.tsx` — AuthLayout (reads `authLayoutTitle` from leaf `staticData`). **Delta vs plan:** shell honours a `skipAuthLayout: true` opt-out in `staticData` for leaves whose own body or sub-layout already wraps in `AuthLayout` (`AuthProvidersLayout`, `AuthEmailsLayout`, `pages/.../auth/third-party.tsx`) — without it those routes would double-wrap (which also doubles `withAuth` + `ProjectLayout`). +- ~~`routes/project/$ref/auth/templates.tsx` — AuthEmailsLayout~~ **Delta vs plan: not landed.** A unified `templates.tsx` sub-shell would force `templates/$templateId.tsx` (which uses plain `AuthLayout`, not `AuthEmailsLayout`) into the wrong wrapping. Instead `templates/index.tsx` and `auth/smtp.tsx` each set `skipAuthLayout: true` and wrap themselves in `AuthEmailsLayout`; `templates/$templateId.tsx` uses the standard auth shell with `authLayoutTitle: 'Emails'`. +- [x] `routes/project/$ref/storage.tsx` — StorageLayout + StorageBucketsLayout (reads `storageLayoutTitle`, optional `skipStorageBucketsLayout`, `storageBucketsLayoutTitle`, `storageBucketsLayoutHideSubtitle` from leaf `staticData`). **Delta vs plan:** the shell wraps in BOTH StorageLayout and StorageBucketsLayout by default — every storage page except bucket-detail pages uses both. Bucket-detail pages set `skipStorageBucketsLayout: true`. `/storage/s3` uses `storageBucketsLayout{Title,HideSubtitle}` to override the inner header. +- [x] `routes/project/$ref/realtime.tsx` — RealtimeLayout (reads `realtimeLayoutTitle` from leaf `staticData`) +- [x] `routes/project/$ref/functions.tsx` — EdgeFunctionsLayout (reads `functionsLayoutTitle` from leaf `staticData`). Honours `skipFunctionsLayout: true` opt-out for the `$functionSlug` subtree, whose `EdgeFunctionDetailsLayout` already wraps `EdgeFunctionsLayout` internally — same pattern as auth.tsx. Sub-shell at `routes/project/$ref/functions/$functionSlug.tsx` provides `EdgeFunctionDetailsLayout` for all 5 slug leaves (reads `edgeFunctionDetailsTitle` from leaf staticData). +- [x] `routes/project/$ref/branches.tsx` — BranchLayout only. **Delta vs plan:** the per-page `PageLayout` (with different titles + primary/secondary actions) stays in each leaf. Hoisted `BranchesPageWrapper` and `MergeRequestsPageWrapper` to top-level exports in their respective `pages/...` files so the route files can import + re-use the same wrapping. +- [x] `routes/project/$ref/logs.tsx` — LogsLayout (reads `logsLayoutTitle` from leaf staticData). Honours `skipLogsLayout: true` for `logs/index` (page handles its own ProjectLayout-wrapped content for the UnifiedLogs / no-permission cases). Refactored `pages/.../logs/index.tsx` to move the inline `` into `getLayout` so it isn't duplicated when the TanStack project shell already provides DefaultLayout. +- [x] `routes/project/$ref/observability.tsx` — ObservabilityLayout (reads `observabilityLayoutTitle` from leaf staticData) +- [x] `routes/project/$ref/advisors.tsx` — AdvisorsLayout (reads `advisorsLayoutTitle` from leaf staticData). Honours `skipAdvisorsLayout: true` opt-out for the rules sub-shell, which provides its own AdvisorsLayout-less-DefaultLayout wrap. Scans whole match chain (same pattern as functions.tsx). +- [x] `routes/project/$ref/advisors/rules.tsx` — sub-shell that inlines the inner body of `AdvisorRulesLayout` (AdvisorsLayout + PageLayout with title/tabs/feature-preview badge), minus the outer DefaultLayout (already provided by the parent project shell). Sets `skipAdvisorsLayout: true` on its own staticData. **Delta vs plan:** the existing `AdvisorRulesLayout` component wraps in DefaultLayout + AdvisorsLayout internally, so reusing it as-is would double-wrap both. Inlined the inner part; the Next-side component is untouched. +- [x] `routes/project/$ref/settings.tsx` — SettingsLayout (reads `settingsLayoutTitle` from leaf staticData). Honours `skipSettingsLayout: true` for `settings/api` (redirect-only page). Adds a sub-shell at `routes/project/$ref/settings/api-keys.tsx` providing `ApiKeysLayout` for both api-keys leaves; `jwt/index` wraps in `JWTKeysLayout` inline since `jwt/legacy` doesn't share it. +- [x] `routes/project/$ref/integrations.tsx` — ProjectIntegrationsLayout (no staticData; all 4 leaves share identical layout). Layout is `withAuth(({ children }) => {children})`, so the shell just wraps `` once. +- [x] `routes/project/$ref/sql.tsx` — EditorBaseLayout + SQLEditorLayout. Twin of editor.tsx; all four leaves share identical layout props so the shell hardcodes them (no `staticData` overrides). EditorBaseLayout wraps in ProjectLayoutWithAuth; SQLEditorLayout adds its own `withAuth` HOC but no extra ProjectLayout — same shape as the table editor (auth check runs twice but no double render). +- [x] `routes/project/$ref/editor.tsx` — EditorBaseLayout + TableEditorLayout. All three leaves share identical layout props so the shell hardcodes them (no `staticData` overrides). EditorBaseLayout wraps in `ProjectLayoutWithAuth` internally; TableEditorLayout's happy path is just a fragment + side-effect (banner) and only wraps in `ProjectLayoutWithAuth` on its no-permission branch — same as Next, no double-wrap in normal use. + +### Auth shell (pathless) + +- [x] `routes/_auth.tsx` — AuthenticationLayout + +--- + +## Pages + +### App shell — `/account/*` + +- [x] A `routes/_app/account/me.tsx` ← `pages/account/me.tsx` +- [x] A `routes/_app/account/security.tsx` ← `pages/account/security.tsx` +- [x] A `routes/_app/account/audit.tsx` ← `pages/account/audit.tsx` +- [x] A `routes/_app/account/tokens/index.tsx` ← `pages/account/tokens.tsx` +- [x] A `routes/_app/account/tokens/scoped.tsx` ← `pages/account/tokens/scoped.tsx` + +### App shell — `/org/$slug/*` + +- [x] A `routes/_app/org/$slug/index.tsx` ← `pages/org/[slug]/index.tsx` +- [x] A `routes/_app/org/$slug/apps.tsx` ← `pages/org/[slug]/apps.tsx` +- [x] A `routes/_app/org/$slug/audit.tsx` ← `pages/org/[slug]/audit.tsx` +- [x] A `routes/_app/org/$slug/billing.tsx` ← `pages/org/[slug]/billing.tsx` +- [x] A `routes/_app/org/$slug/documents.tsx` ← `pages/org/[slug]/documents.tsx` +- [x] A `routes/_app/org/$slug/general.tsx` ← `pages/org/[slug]/general.tsx` +- [x] A `routes/_app/org/$slug/integrations.tsx` ← `pages/org/[slug]/integrations.tsx` +- [x] A `routes/_app/org/$slug/security.tsx` ← `pages/org/[slug]/security.tsx` +- [x] A `routes/_app/org/$slug/sso.tsx` ← `pages/org/[slug]/sso.tsx` +- [x] A `routes/_app/org/$slug/team.tsx` ← `pages/org/[slug]/team.tsx` +- [x] A `routes/_app/org/$slug/usage.tsx` ← `pages/org/[slug]/usage.tsx` +- [x] A `routes/_app/org/$slug/private-apps/index.tsx` ← `pages/org/[slug]/private-apps/index.tsx` +- [x] A `routes/_app/org/$slug/webhooks/index.tsx` ← `pages/org/[slug]/webhooks/index.tsx` +- [x] A `routes/_app/org/$slug/webhooks/$endpointId.tsx` ← `pages/org/[slug]/webhooks/[endpointId].tsx` +- [x] A `routes/_app/org/index.tsx` ← `pages/org/index.tsx` (redirect) + +### App shell — top-level pages + +- [x] A `routes/_app/organizations.tsx` ← `pages/organizations.tsx` (page default already withAuth-wrapped; PageLayout wraps body) +- [x] `routes/_app/new/index.tsx` ← `pages/new/index.tsx` (inlines WizardLayout; sets `defaultLayoutHeaderTitle: 'New organization'` + `hideMobileMenu: true` on staticData). **Delta vs plan:** no `_app/new.tsx` sub-shell — `new/$slug` doesn't fit under \_app and uses a different inner wrapper (PageLayout), so a shared shell wouldn't share anything. +- [x] A `routes/new/$slug.tsx` ← `pages/new/[slug].tsx` **Delta vs plan:** placed at top-level rather than under `_app/` — Next getLayout omits AppLayout and uses PageLayout (not WizardLayout) inside DefaultLayout, so leaf inlines the full DefaultLayout + PageLayout wrap itself. +- [x] A `routes/aws-marketplace-onboarding.tsx` ← `pages/aws-marketplace-onboarding.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses its own `LinkAwsMarketplaceLayout` and doesn't want `AppLayout` + `DefaultLayout` wrapping. +- [x] A `routes/claim-project.tsx` ← `pages/claim-project.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses its own `` + `
` layout and doesn't want `AppLayout` + `DefaultLayout` wrapping. +- [x] A `routes/join.tsx` ← `pages/join.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses a centered-div layout and doesn't want `AppLayout` + `DefaultLayout` wrapping. +- [x] `routes/_app/support/new.tsx` ← `pages/support/new.tsx` (sets `hideMobileMenu: true` staticData; existing page is `withAuth`-wrapped so no beforeLoad migration needed yet) +- [x] `routes/_app/support/link.tsx` ← `pages/support/link.tsx` + +### App shell — integrations + +- [x] A `routes/integrations/vercel/install.tsx` ← `pages/integrations/vercel/install.tsx` +- [x] A `routes/integrations/vercel/$slug/marketplace/choose-project.tsx` ← `pages/integrations/vercel/[slug]/marketplace/choose-project.tsx` +- [x] A `routes/integrations/vercel/$slug/deploy-button/new-project.tsx` ← `pages/integrations/vercel/[slug]/deploy-button/new-project.tsx` +- [x] A `routes/integrations/github/authorize.tsx` ← `pages/integrations/github/authorize.tsx` **Delta vs plan:** placed at top-level rather than under `_app/` — Next page has no getLayout (renders bare), so adding AppLayout/DefaultLayout via \_app would be a behaviour change. + +### Project shell — home + +- [x] A `routes/project/$ref/index.tsx` ← `pages/project/[ref]/index.tsx` (route wraps in `ProjectLayoutWithAuth` itself — see shell delta above) +- [x] `routes/project/$ref/merge.tsx` ← `pages/project/[ref]/merge.tsx` (leaf wraps body in `ProjectLayoutWithAuth`; parent `project/$ref.tsx` shell provides DefaultLayout) + +### Project shell — `/api/*` + +- [x] `routes/project/$ref/api/index.tsx` ← `pages/project/[ref]/api/index.tsx` (redirect-only page; no extra wrap needed beyond the parent DefaultLayout shell) + +### Project shell — `/database/*` + +- [x] A `routes/project/$ref/database/schemas.tsx` ← `pages/project/[ref]/database/schemas.tsx` +- [x] A `routes/project/$ref/database/extensions.tsx` ← `pages/project/[ref]/database/extensions.tsx` +- [x] A `routes/project/$ref/database/functions.tsx` ← `pages/project/[ref]/database/functions.tsx` +- [x] A `routes/project/$ref/database/indexes.tsx` ← `pages/project/[ref]/database/indexes.tsx` +- [x] A `routes/project/$ref/database/migrations.tsx` ← `pages/project/[ref]/database/migrations.tsx` +- [x] A `routes/project/$ref/database/roles.tsx` ← `pages/project/[ref]/database/roles.tsx` +- [x] A `routes/project/$ref/database/settings.tsx` ← `pages/project/[ref]/database/settings.tsx` +- [x] A `routes/project/$ref/database/types.tsx` ← `pages/project/[ref]/database/types.tsx` +- [x] A `routes/project/$ref/database/column-privileges.tsx` ← `pages/project/[ref]/database/column-privileges.tsx` +- [x] A `routes/project/$ref/database/tables/index.tsx` ← `pages/project/[ref]/database/tables/index.tsx` +- [x] A `routes/project/$ref/database/tables/$id.tsx` ← `pages/project/[ref]/database/tables/[id].tsx` +- [x] A `routes/project/$ref/database/publications/index.tsx` ← `pages/project/[ref]/database/publications/index.tsx` +- [x] A `routes/project/$ref/database/publications/$id.tsx` ← `pages/project/[ref]/database/publications/[id].tsx` +- [x] A `routes/project/$ref/database/replication/index.tsx` ← `pages/project/[ref]/database/replication/index.tsx` +- [x] A `routes/project/$ref/database/replication/$pipelineId.tsx` ← `pages/project/[ref]/database/replication/[pipelineId].tsx` +- [x] A `routes/project/$ref/database/replication/replica/$replicaId.tsx` ← `pages/project/[ref]/database/replication/replica/[replicaId].tsx` +- [x] A `routes/project/$ref/database/triggers/index.tsx` ← `pages/project/[ref]/database/triggers/index.tsx` +- [x] A `routes/project/$ref/database/triggers/data.tsx` ← `pages/project/[ref]/database/triggers/data.tsx` (sub-shell at `database/triggers.tsx` provides PageLayout + nav, parent shell provides DatabaseLayout) +- [x] A `routes/project/$ref/database/triggers/event.tsx` ← `pages/project/[ref]/database/triggers/event.tsx` (same as data) +- [x] A `routes/project/$ref/database/backups/pitr.tsx` ← `pages/project/[ref]/database/backups/pitr.tsx` +- [x] A `routes/project/$ref/database/backups/restore-to-new-project.tsx` ← `pages/project/[ref]/database/backups/restore-to-new-project.tsx` +- [x] A `routes/project/$ref/database/backups/scheduled.tsx` ← `pages/project/[ref]/database/backups/scheduled.tsx` + +### Project shell — `/auth/*` + +- [x] A `routes/project/$ref/auth/overview.tsx` ← `pages/project/[ref]/auth/overview.tsx` +- [x] A `routes/project/$ref/auth/users.tsx` ← `pages/project/[ref]/auth/users.tsx` +- [x] A `routes/project/$ref/auth/policies.tsx` ← `pages/project/[ref]/auth/policies.tsx` +- [x] A `routes/project/$ref/auth/providers.tsx` ← `pages/project/[ref]/auth/providers.tsx` (sets `skipAuthLayout: true`, wraps in `AuthProvidersLayout` directly) +- [x] A `routes/project/$ref/auth/mfa.tsx` ← `pages/project/[ref]/auth/mfa.tsx` +- [x] A `routes/project/$ref/auth/hooks.tsx` ← `pages/project/[ref]/auth/hooks.tsx` +- [x] A `routes/project/$ref/auth/smtp.tsx` ← `pages/project/[ref]/auth/smtp.tsx` (sets `skipAuthLayout: true`, wraps in `AuthEmailsLayout` directly) +- [x] A `routes/project/$ref/auth/sessions.tsx` ← `pages/project/[ref]/auth/sessions.tsx` +- [x] A `routes/project/$ref/auth/passkeys.tsx` ← `pages/project/[ref]/auth/passkeys.tsx` +- [x] A `routes/project/$ref/auth/performance.tsx` ← `pages/project/[ref]/auth/performance.tsx` +- [x] A `routes/project/$ref/auth/protection.tsx` ← `pages/project/[ref]/auth/protection.tsx` +- [x] A `routes/project/$ref/auth/rate-limits.tsx` ← `pages/project/[ref]/auth/rate-limits.tsx` +- [x] A `routes/project/$ref/auth/third-party.tsx` ← `pages/project/[ref]/auth/third-party.tsx` (sets `skipAuthLayout: true` — page body inlines `` which already wraps ``) +- [x] A `routes/project/$ref/auth/oauth-apps.tsx` ← `pages/project/[ref]/auth/oauth-apps.tsx` +- [x] A `routes/project/$ref/auth/oauth-server.tsx` ← `pages/project/[ref]/auth/oauth-server.tsx` +- [x] A `routes/project/$ref/auth/url-configuration.tsx` ← `pages/project/[ref]/auth/url-configuration.tsx` +- [x] A `routes/project/$ref/auth/audit-logs.tsx` ← `pages/project/[ref]/auth/audit-logs.tsx` +- [x] A `routes/project/$ref/auth/templates/index.tsx` ← `pages/project/[ref]/auth/templates/index.tsx` (sets `skipAuthLayout: true`, wraps in `AuthEmailsLayout` directly) +- [x] A `routes/project/$ref/auth/templates/$templateId.tsx` ← `pages/project/[ref]/auth/templates/[templateId].tsx` (`authLayoutTitle: 'Emails'` — page uses plain `AuthLayout`, not `AuthEmailsLayout`) + +### Project shell — `/storage/*` + +- [x] A `routes/project/$ref/storage/s3.tsx` ← `pages/project/[ref]/storage/s3.tsx` +- [x] A `routes/project/$ref/storage/files/index.tsx` ← `pages/project/[ref]/storage/files/index.tsx` +- [x] A `routes/project/$ref/storage/files/policies.tsx` ← `pages/project/[ref]/storage/files/policies.tsx` +- [x] A `routes/project/$ref/storage/files/settings.tsx` ← `pages/project/[ref]/storage/files/settings.tsx` +- [x] A `routes/project/$ref/storage/files/buckets/$bucketId.tsx` ← `pages/project/[ref]/storage/files/buckets/[bucketId].tsx` (sets `skipStorageBucketsLayout: true`) +- [x] A `routes/project/$ref/storage/analytics/index.tsx` ← `pages/project/[ref]/storage/analytics/index.tsx` +- [x] A `routes/project/$ref/storage/analytics/buckets/$bucketId.tsx` ← `pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx` (sets `skipStorageBucketsLayout: true`) +- [x] A `routes/project/$ref/storage/vectors/index.tsx` ← `pages/project/[ref]/storage/vectors/index.tsx` +- [x] A `routes/project/$ref/storage/vectors/buckets/$bucketId.tsx` ← `pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx` (sets `skipStorageBucketsLayout: true`) + +### Project shell — `/realtime/*` + +- [x] A `routes/project/$ref/realtime/inspector.tsx` ← `pages/project/[ref]/realtime/inspector.tsx` +- [x] A `routes/project/$ref/realtime/policies.tsx` ← `pages/project/[ref]/realtime/policies.tsx` +- [x] A `routes/project/$ref/realtime/settings.tsx` ← `pages/project/[ref]/realtime/settings.tsx` + +### Project shell — `/functions/*` + +- [x] A `routes/project/$ref/functions/index.tsx` ← `pages/project/[ref]/functions/index.tsx` (route wraps in exported `EdgeFunctionsIndexPageWrapper` for the inline PageHeader + actions) +- [x] A `routes/project/$ref/functions/new.tsx` ← `pages/project/[ref]/functions/new.tsx` +- [x] A `routes/project/$ref/functions/secrets.tsx` ← `pages/project/[ref]/functions/secrets.tsx` (route wraps in exported `SecretsPageWrapper`) +- [x] A `routes/project/$ref/functions/$functionSlug/index.tsx` ← `pages/project/[ref]/functions/[functionSlug]/index.tsx` +- [x] A `routes/project/$ref/functions/$functionSlug/code.tsx` ← `pages/project/[ref]/functions/[functionSlug]/code.tsx` +- [x] A `routes/project/$ref/functions/$functionSlug/details.tsx` ← `pages/project/[ref]/functions/[functionSlug]/details.tsx` +- [x] A `routes/project/$ref/functions/$functionSlug/invocations.tsx` ← `pages/project/[ref]/functions/[functionSlug]/invocations.tsx` +- [x] A `routes/project/$ref/functions/$functionSlug/logs.tsx` ← `pages/project/[ref]/functions/[functionSlug]/logs.tsx` + +### Project shell — `/branches/*` + +- [x] A `routes/project/$ref/branches/index.tsx` ← `pages/project/[ref]/branches/index.tsx` (route wraps in exported `BranchesPageWrapper` to preserve the page's `PageLayout` + Create-branch action) +- [x] A `routes/project/$ref/branches/merge-requests.tsx` ← `pages/project/[ref]/branches/merge-requests.tsx` (route wraps in exported `MergeRequestsPageWrapper`) + +### Project shell — `/logs/*` + +- [x] A `routes/project/$ref/logs/index.tsx` ← `pages/project/[ref]/logs/index.tsx` (sets `skipLogsLayout: true`; page handles its own ProjectLayout; DefaultLayout moved to page's `getLayout` so Next still wraps it) +- [x] A `routes/project/$ref/logs/auth-logs.tsx` ← `pages/project/[ref]/logs/auth-logs.tsx` +- [x] A `routes/project/$ref/logs/cron-logs.tsx` ← `pages/project/[ref]/logs/cron-logs.tsx` +- [x] A `routes/project/$ref/logs/dedicated-pooler-logs.tsx` ← `pages/project/[ref]/logs/dedicated-pooler-logs.tsx` +- [x] A `routes/project/$ref/logs/edge-functions-logs.tsx` ← `pages/project/[ref]/logs/edge-functions-logs.tsx` +- [x] A `routes/project/$ref/logs/edge-logs.tsx` ← `pages/project/[ref]/logs/edge-logs.tsx` +- [x] A `routes/project/$ref/logs/pg-upgrade-logs.tsx` ← `pages/project/[ref]/logs/pg-upgrade-logs.tsx` +- [x] A `routes/project/$ref/logs/pgcron-logs.tsx` ← `pages/project/[ref]/logs/pgcron-logs.tsx` +- [x] A `routes/project/$ref/logs/pooler-logs.tsx` ← `pages/project/[ref]/logs/pooler-logs.tsx` +- [x] A `routes/project/$ref/logs/postgres-logs.tsx` ← `pages/project/[ref]/logs/postgres-logs.tsx` +- [x] A `routes/project/$ref/logs/postgrest-logs.tsx` ← `pages/project/[ref]/logs/postgrest-logs.tsx` +- [x] A `routes/project/$ref/logs/realtime-logs.tsx` ← `pages/project/[ref]/logs/realtime-logs.tsx` +- [x] A `routes/project/$ref/logs/replication-logs.tsx` ← `pages/project/[ref]/logs/replication-logs.tsx` +- [x] A `routes/project/$ref/logs/storage-logs.tsx` ← `pages/project/[ref]/logs/storage-logs.tsx` +- [x] A `routes/project/$ref/logs/explorer/index.tsx` ← `pages/project/[ref]/logs/explorer/index.tsx` +- [x] A `routes/project/$ref/logs/explorer/recent.tsx` ← `pages/project/[ref]/logs/explorer/recent.tsx` +- [x] A `routes/project/$ref/logs/explorer/saved.tsx` ← `pages/project/[ref]/logs/explorer/saved.tsx` +- [x] A `routes/project/$ref/logs/explorer/templates.tsx` ← `pages/project/[ref]/logs/explorer/templates.tsx` + +### Project shell — `/observability/*` + +- [x] A `routes/project/$ref/observability/index.tsx` ← `pages/project/[ref]/observability/index.tsx` +- [x] A `routes/project/$ref/observability/$id.tsx` ← `pages/project/[ref]/observability/[id].tsx` +- [x] A `routes/project/$ref/observability/auth.tsx` ← `pages/project/[ref]/observability/auth.tsx` +- [x] A `routes/project/$ref/observability/database.tsx` ← `pages/project/[ref]/observability/database.tsx` +- [x] A `routes/project/$ref/observability/api-overview.tsx` ← `pages/project/[ref]/observability/api-overview.tsx` +- [x] A `routes/project/$ref/observability/edge-functions.tsx` ← `pages/project/[ref]/observability/edge-functions.tsx` +- [x] A `routes/project/$ref/observability/postgrest.tsx` ← `pages/project/[ref]/observability/postgrest.tsx` +- [x] A `routes/project/$ref/observability/query-insights.tsx` ← `pages/project/[ref]/observability/query-insights.tsx` +- [x] A `routes/project/$ref/observability/query-performance.tsx` ← `pages/project/[ref]/observability/query-performance.tsx` +- [x] A `routes/project/$ref/observability/realtime.tsx` ← `pages/project/[ref]/observability/realtime.tsx` +- [x] A `routes/project/$ref/observability/storage.tsx` ← `pages/project/[ref]/observability/storage.tsx` + +### Project shell — `/advisors/*` + +- [x] A `routes/project/$ref/advisors/performance.tsx` ← `pages/project/[ref]/advisors/performance.tsx` +- [x] A `routes/project/$ref/advisors/security.tsx` ← `pages/project/[ref]/advisors/security.tsx` +- [x] A `routes/project/$ref/advisors/rules/performance.tsx` ← `pages/project/[ref]/advisors/rules/performance.tsx` +- [x] A `routes/project/$ref/advisors/rules/security.tsx` ← `pages/project/[ref]/advisors/rules/security.tsx` + +### Project shell — `/settings/*` + +- [x] A `routes/project/$ref/settings/general.tsx` ← `pages/project/[ref]/settings/general.tsx` +- [x] A `routes/project/$ref/settings/addons.tsx` ← `pages/project/[ref]/settings/addons.tsx` +- [x] A `routes/project/$ref/settings/api.tsx` ← `pages/project/[ref]/settings/api.tsx` (sets `skipSettingsLayout: true` — page is a useEffect redirect) +- [x] A `routes/project/$ref/settings/compute-and-disk.tsx` ← `pages/project/[ref]/settings/compute-and-disk.tsx` +- [x] A `routes/project/$ref/settings/dashboard.tsx` ← `pages/project/[ref]/settings/dashboard.tsx` +- [x] A `routes/project/$ref/settings/infrastructure.tsx` ← `pages/project/[ref]/settings/infrastructure.tsx` +- [x] A `routes/project/$ref/settings/integrations.tsx` ← `pages/project/[ref]/settings/integrations.tsx` +- [x] A `routes/project/$ref/settings/log-drains.tsx` ← `pages/project/[ref]/settings/log-drains.tsx` +- [x] A `routes/project/$ref/settings/api-keys/index.tsx` ← `pages/project/[ref]/settings/api-keys/index.tsx` (under `api-keys.tsx` sub-shell with ApiKeysLayout) +- [x] A `routes/project/$ref/settings/api-keys/legacy.tsx` ← `pages/project/[ref]/settings/api-keys/legacy.tsx` (under `api-keys.tsx` sub-shell) +- [x] A `routes/project/$ref/settings/billing/usage.tsx` ← `pages/project/[ref]/settings/billing/usage.tsx` +- [x] A `routes/project/$ref/settings/jwt/index.tsx` ← `pages/project/[ref]/settings/jwt/index.tsx` (wraps in JWTKeysLayout inline) +- [x] A `routes/project/$ref/settings/jwt/legacy.tsx` ← `pages/project/[ref]/settings/jwt/legacy.tsx` +- [x] A `routes/project/$ref/settings/webhooks/index.tsx` ← `pages/project/[ref]/settings/webhooks/index.tsx` +- [x] A `routes/project/$ref/settings/webhooks/$endpointId.tsx` ← `pages/project/[ref]/settings/webhooks/[endpointId].tsx` + +### Project shell — `/integrations/*` + +- [x] `routes/project/$ref/integrations/index.tsx` ← `pages/project/[ref]/integrations/index.tsx` +- [x] `routes/project/$ref/integrations/$id/index.tsx` ← `pages/project/[ref]/integrations/[id]/index.tsx` +- [x] `routes/project/$ref/integrations/$id/$pageId/index.tsx` ← `pages/project/[ref]/integrations/[id]/[pageId]/index.tsx` +- [x] `routes/project/$ref/integrations/$id/$pageId/$childId/index.tsx` ← `pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx` + +### Project shell — `/sql/*` + +- [x] A `routes/project/$ref/sql/index.tsx` ← `pages/project/[ref]/sql/index.tsx` +- [x] A `routes/project/$ref/sql/$id.tsx` ← `pages/project/[ref]/sql/[id].tsx` +- [x] A `routes/project/$ref/sql/templates.tsx` ← `pages/project/[ref]/sql/templates.tsx` +- [x] A `routes/project/$ref/sql/quickstarts.tsx` ← `pages/project/[ref]/sql/quickstarts.tsx` + +### Project shell — `/editor/*` + +- [x] A `routes/project/$ref/editor/index.tsx` ← `pages/project/[ref]/editor/index.tsx` +- [x] A `routes/project/$ref/editor/$id.tsx` ← `pages/project/[ref]/editor/[id].tsx` +- [x] A `routes/project/$ref/editor/new.tsx` ← `pages/project/[ref]/editor/new.tsx` + +### Auth shell — `/sign-in`, `/sign-up`, etc. + +- [x] A `routes/_auth/sign-in.tsx` ← `pages/sign-in.tsx` +- [x] A `routes/_auth/sign-up.tsx` ← `pages/sign-up.tsx` +- [x] A `routes/_auth/sign-in-sso.tsx` ← `pages/sign-in-sso.tsx` +- [x] A `routes/_auth/sign-in-partner.tsx` ← `pages/sign-in-partner.tsx` +- [x] A `routes/_auth/sign-in-mfa.tsx` ← `pages/sign-in-mfa.tsx` (page inlines SignInLayout) +- [x] A `routes/_auth/forgot-password.tsx` ← `pages/forgot-password.tsx` +- [x] A `routes/_auth/forgot-password-mfa.tsx` ← `pages/forgot-password-mfa.tsx` (page inlines ForgotPasswordLayout) +- [x] A `routes/_auth/reset-password.tsx` ← `pages/reset-password.tsx` (page default already withAuth-wrapped) +- [x] A `routes/_auth/cli/login.tsx` ← `pages/cli/login.tsx` (page inlines APIAuthorizationLayout, withAuth) +- [x] A `routes/_auth/partners/stripe/projects/login.tsx` ← `pages/partners/stripe/projects/login.tsx` (page inlines APIAuthorizationLayout, withAuth) + +### Standalone (no shared shell) + +- [x] B `routes/index.tsx` — redirect-only root route. Mirrors the Next.js `redirects()` rules in `next.config.ts`: platform sends users to `/org` (or `/new/new-project` when deep-linked with `?next=new-project`), self-hosted sends them to `/project/default`. **Follow-up:** the redirect targets currently use `href` (full reload) because they were on the Next side when this was written; switch to `to` now that all of them live in the TanStack tree. +- [x] A `routes/authorize.tsx` ← `pages/authorize.tsx` (APIAuthorizationLayout) +- [x] A `routes/redeem.tsx` ← `pages/redeem.tsx` (RedeemCreditsLayout) +- [x] A `routes/logout.tsx` ← `pages/logout.tsx` +- [x] A `routes/maintenance.tsx` ← `pages/maintenance.tsx` + +### Error pages (handled at root) + +- [x] A `__root.tsx` — wired `notFoundComponent` to `pages/404.tsx` +- [x] `__root.tsx` — wired `errorComponent` to `pages/500.tsx`. Mirrors the in-tree `react-error-boundary` Sentry capture (`scope.setTag('routerErrorComponent', true)`) so router-level errors (loader/component-render failures before the in-tree boundary mounts) still report. `pages/_error.jsx` stays load-bearing under Next but isn't reached at runtime under TanStack — it's the pages-router catch-all that has no TanStack equivalent. + +--- + +## API routes + +**Strategy — shim + re-export.** `compat/next/api.ts` exposes +`toWebHandler(nextHandler)` that adapts a `(req, res) => …` Next.js handler +into a TanStack Start Web-fetch handler. Each `routes/api/...` file imports +the default export from `pages/api/...`, wraps with `toWebHandler`, and +registers via `createFileRoute(...).server.handlers`. `apiWrapper` and +`apiAuthenticate` stay untouched — they run inside the shim, seeing a +NextApiRequest-shaped `req` and a proxy `res`. + +**Path conventions** — `pages/api/foo/[bar]/baz.ts` → `routes/api/foo/$bar/baz.ts`; +`pages/api/foo/[[...slug]].ts` → `routes/api/foo/$.ts`. + +**Shim coverage.** The proxy `req` / `res` cover both the buffered and +streaming patterns that pages-router handlers use: + +- **Buffered responses** — `res.status`/`setHeader`/`json`/`send`/`write`/ + `end` accumulate into a single `Response` body when the handler returns. +- **Streaming responses** — `res.writeHead(status, headers?)` (or + `res.flushHeaders()`) flips the proxy into streaming mode: a Web + `ReadableStream` opens, buffered chunks flush into it, subsequent + `res.write(chunk)` enqueues live, `res.end()` closes it. `finalize()` + returns the `Response` while the handler keeps pushing chunks. This is + what makes `result.pipeUIMessageStreamToResponse(res, …)` (AI SDK) + stream token-by-token to the browser. +- **Client abort** — Web `Request.signal` is plumbed through as + `req.on('close' | 'aborted', …)`. AI handlers that wire + `abortController.abort()` off those events keep working. +- **EventEmitter surface** — `req.on`/`once`/`off`/`emit` (events `close` + / `aborted` are real; other names accepted but no-op). `res.on`/etc. + are no-op stubs so pipe helpers attaching `drain`/`close`/`error` + listeners don't crash. +- **Body parsing** — JSON and `application/x-www-form-urlencoded` parsed + to `req.body`; everything else is the raw text. Multipart inbound is + not implemented — no studio handler reads multipart in. + +Two routes still bypass the shim because they're easier to write +Web-natively from scratch: + +- `routes/api/v1/projects/$ref/functions/$slug/body.ts` — multipart + streaming OUT (artifact download). Builds the `Response` body as a + `ReadableStream`; each artifact file converts via + `Readable.toWeb(createReadStream(...))` and pulls chunk-by-chunk into + the stream. +- `routes/api/mcp/index.ts` — uses MCP SDK's + `WebStandardStreamableHTTPServerTransport` (`handleRequest(request)` + returns a `Response` directly). +- `pages/api/ai/docs.ts` was already edge-runtime / Web-Response native + — direct re-export, no shim involved. + +Tracking below is coarse — each bullet is a `pages/api/**` subtree. Check off +once every file in the subtree has a `routes/api/**` counterpart. Expand into +per-file items only when a subtree has special cases. + +- [x] `routes/api/get-ip-address.ts` — canary port (validates the shim) +- [x] `routes/api/**` — root-level simple endpoints (`check-cname`, + `cli-release-version`, `enabled-features-overrides`, `generate-attachment-url`, + `get-deployment-commit`, `get-utc-time`, `status-override`) +- [x] `routes/api/ai/**` — AI endpoints (`docs.ts` direct-ported as Web-native) +- [x] `routes/api/connect/**` +- [x] `routes/api/content/**` +- [x] `routes/api/edge-functions/**` +- [x] `routes/api/integrations/**` +- [x] `routes/api/platform/**` (60 files, scripted port) +- [x] `routes/api/v1/**` — except `body.ts` (streaming rewrite) +- [x] `routes/api/v1/projects/$ref/functions/$slug/body.ts` — Web-streams rewrite. Returns a `Response` whose body is a `ReadableStream`; each artifact file is converted via `Readable.toWeb(createReadStream(...))` and pulled chunk-by-chunk into the multipart stream. Skips the `apiWrapper` since `getFunctionsArtifactStore` already asserts self-hosted mode (the pages-router `withAuth` was a no-op outside `IS_PLATFORM`). +- [x] `routes/api/mcp/index.ts` — uses `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js`. Takes a Web `Request`, returns a `Response` directly — no shim needed. Query parsing pulled from `request.url`'s search params; headers passed through unchanged. +- [x] `routes/api/incident-banner.ts`, `routes/api/incident-status.ts` — App + Router routes under `app/api/**` (already Web-native, direct re-export) + +--- + +## Compat shim surface (`compat/next/`) + +The Next compat shims stay alive as long as any `pages/...` file is +load-bearing. Listed here so the cleanup PR knows what to delete / +inline. + +- `router.ts` — `useRouter()` for hook callers (TanStack `useRouter` + + `useLocation` + `useMatches` + `useParams` + `useSearch` glued + together), plus a `default` export (`SingletonRouter` shape) for the + one module-scope `import router from 'next/router'` consumer + (Support/DiscordCTACard) that reads `router.basePath` outside React. + `router.pathname` strips the trailing slash TanStack appends to index + routes (without it, `router.pathname.split('/')[3]` returns `''` + instead of `undefined` for index pages and the project sidebar's + active-route check breaks). +- `_router-events.ts` — adapts `router.events.on(event, handler)` onto + `router.subscribe(tsEvent, …)`. Forwards Next's `(url, { shallow })` + args. Maps `routeChangeStart` / `routeChangeComplete` / + `beforeHistoryChange` / `hashChangeStart` / `hashChangeComplete`. + **Known gap:** Next's throw-from-`routeChangeStart`-to-cancel pattern + isn't supportable — `subscribe` is fire-and-forget. + `usePreventNavigationOnUnsavedChanges` relies on it and needs + migrating to TanStack's `useBlocker` separately. +- `api.ts` — `toWebHandler(nextHandler)`. See **API routes → Shim + coverage** above. +- `link.tsx`, `navigation.ts`, `dynamic.tsx`, `image.tsx`, + `legacy/image.tsx`, `script.tsx`, `head.tsx`, `server.ts` — comprehensive + drop-in replacements for the `next/*` modules studio imports. All + bundled via `vite.config.ts`'s `nextCompat()` plugin (alias) + + `ssr.noExternal: [/^next(\/|$)/]` so the shims always win over the + real Next packages. + +--- + +## Build / bundler workarounds + +`vite.config.ts` carries two classes of build-time guard that exist +purely because of how Rolldown chunks our specific dependency graph. +They should be revisited (and ideally lifted) once the migration is +done. + +### `manualChunks` pins + +Pin shared library code into dedicated chunks so per-component chunks +can't import from a chunk that (transitively) imports them back — +chunk-level cycles surface in the browser as +`TypeError: is not a function` at module-load time. + +- `class-variance-authority` — entry #1 in CIRCULAR_IMPORTS.md. +- `lucide-react` — keeps Lucide icons from being per-icon-split into + chunks that import `createLucideIcon` back from the `ui` chunk + (`folder-open-.js` was the canary). +- `react-vendor` (react + react-dom + scheduler + jsx-runtime) — pinned + before `lucide-react` so Rolldown doesn't suck React into the + lucide chunk for CJS interop and shift live-bindings across the rest + of the graph (`Alert-.js` was the canary). + +All three are documented in `CIRCULAR_IMPORTS.md` — slated for a +follow-up structural fix in `packages/ui` so the pins can be lifted. + +### `assertNoChunkCycles` build plugin + +Vite plugin that runs Tarjan's SCC on the emitted chunk graph in +`generateBundle` and fails the build if any unknown chunk cycle exists. +The pre-existing CVA cycle is allowlisted by chunk basename +(`KNOWN_CHUNK_CYCLES` constant) so the build still passes; any **new** +cycle blocks the build with a message pointing at CIRCULAR_IMPORTS.md. + +Keep this plugin even after migration — it's not a Next-related shim, +it's general protection against this entire class of bug. Just clear +the allowlist when the underlying cycle is gone. + +### Other build-side migration changes + +- `pnpm-workspace.yaml` catalog now includes `@tanstack/react-router`, + `@tanstack/react-start`, `@tanstack/react-table` so studio and + ui-library stay aligned. `react-query` is **not** in the catalog yet + — three consumers (studio, docs, ui-library) sit on different 5.x + ranges and unifying them is a separate decision. +- `NODE_OPTIONS=--max-old-space-size=8192` is set on the studio + `dev` script — Vite's Rolldown-RC frontend hits the default 4 GB + ceiling when chewing through studio's module graph in watch mode. + +--- + +## Deferred / revisit + +- ~~`pages/org/_/[[...routeSlug]].tsx`~~ landed as `routes/org.[_].tsx` + `routes/org.[_].$.tsx`. **Naming delta:** path-as-filename form (not `routes/org/[_]/index.tsx`) because the index-file form trips a router-generator bug at `getRouteNodes.js:132` — when an `index.tsx` has a bracket-escaped _parent_ segment, `originalRoutePath` gets wiped wholesale and the escape info is lost, so `_` gets stripped as pathless. The path-as-filename form keeps the last segment non-index and avoids the bug branch entirely. Next page accepts either Next-style `routeSlug` (string[]) or TanStack-style `_splat` (string) and normalises to the array shape. +- ~~`pages/project/_/[[...routeSlug]].tsx`~~ landed as `routes/project.[_].tsx` + `routes/project.[_].$.tsx`. Same naming-delta rationale as the org catch-alls above. + +### Cleanup checklist (after every `pages/...` file is gone) + +- Switch `routes/index.tsx` redirects from `href` to `to` — all targets + now live in the TanStack tree. +- Migrate `usePreventNavigationOnUnsavedChanges` from `router.events.on('routeChangeStart', …)` (throw-to-cancel pattern) to TanStack's `useBlocker`. +- Drop the `_splat` / `routeSlug` normalisation block from + `pages/org/_/[[...routeSlug]].tsx` + `pages/project/_/[[...routeSlug]].tsx` (only there to keep both runtimes mounting the same body). +- Remove `RouteValidationWrapper` + `next/router` compat shim usage from `__root.tsx`. +- Remove `compat/next/` directory entirely once no `next/*` import remains in workspace source. +- Lift `manualChunks` pins (`class-variance-authority`, `lucide-react`, `react-vendor`) once the structural fix in `packages/ui` lands — see CIRCULAR_IMPORTS.md. Keep `assertNoChunkCycles`; just clear `KNOWN_CHUNK_CYCLES`. +- Delete `pages/_app.tsx`, `pages/_document.tsx`, `pages/_error.jsx`, `pages/500.tsx`, `pages/404.tsx` (Next-only catch-alls; TanStack equivalents on `__root.tsx`). +- Drop the `dev:next` / `build:next` / `start:next` scripts from `apps/studio/package.json` once we're committed to TanStack. +- Delete this file. diff --git a/apps/studio/api/server.js b/apps/studio/api/server.js new file mode 100644 index 00000000000..3d12a8e55b3 --- /dev/null +++ b/apps/studio/api/server.js @@ -0,0 +1,25 @@ +// STUDIO_FRAMEWORK gates whether this function actually serves the TanStack +// SSR handler. Vercel auto-detects every file under /api as a Function +// regardless of the framework preset (vercel.com/docs/functions), so we +// can't keep this file from being deployed in the Next.js prod build — we +// just make it inert when the env var is unset. +const isTanstack = process.env.STUDIO_FRAMEWORK === 'tanstack' + +// Computed path keeps `dist/server/server.js` out of Vercel's function +// bundler's static analysis. In the Next.js prod deploy the `dist/` tree +// doesn't exist, but Vercel still bundles this file because it lives under +// `api/`. With the .join() the bundler treats the import as runtime-only +// and the missing dist/ isn't a build error. In TanStack mode the SSR +// bundle is shipped into the function via the `functions['api/server.js'] +// .includeFiles` config in vercel.ts. +const tanstackEntry = ['..', 'dist', 'server', 'server.js'].join('/') + +const handler = isTanstack + ? (await import(tanstackEntry)).default + : { fetch: () => new Response('Not Found', { status: 404 }) } + +// Vercel's Web API handler convention: export an object with `fetch(request)`. +// TanStack's server build is already shaped that way — default-export it +// verbatim and Vercel hands us a real Web Request. +// eslint-disable-next-line no-restricted-exports +export default handler diff --git a/apps/studio/compat/next/_router-events.ts b/apps/studio/compat/next/_router-events.ts new file mode 100644 index 00000000000..87c9d3ca83d --- /dev/null +++ b/apps/studio/compat/next/_router-events.ts @@ -0,0 +1,138 @@ +// Adapter that exposes a Next pages-router `events` API on top of +// TanStack Router's `router.subscribe`. Handlers receive Next's +// signature: `(url: string, options: { shallow: boolean })`. +// +// TanStack's `RouterEvents` type (router-core/dist/esm/router.d.ts): +// - onBeforeNavigate — fires before the URL transition begins +// - onBeforeLoad — fires after navigation, before route loaders run +// - onLoad — fires while loaders run +// - onResolved — fires after route is fully resolved +// - onBeforeRouteMount +// - onRendered +// +// Each TanStack event payload carries +// { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } +// — we forward `toLocation.href` as the URL arg. +// +// Known gap: Next's `routeChangeStart` lets handlers throw to cancel the +// navigation. TanStack's `subscribe` is fire-and-forget; cancellation +// requires `useBlocker` instead. `usePreventNavigationOnUnsavedChanges` +// relies on the throw-to-cancel pattern and will need migrating to +// `useBlocker` separately. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRouter = any +type Handler = (url: string, options: { shallow: boolean }) => void + +type NextEventName = + | 'routeChangeStart' + | 'routeChangeComplete' + | 'routeChangeError' + | 'beforeHistoryChange' + | 'hashChangeStart' + | 'hashChangeComplete' + +type TanStackEventName = + | 'onBeforeNavigate' + | 'onBeforeLoad' + | 'onLoad' + | 'onResolved' + | 'onBeforeRouteMount' + | 'onRendered' + +type TanStackNavigationEvent = { + type: TanStackEventName + fromLocation?: { href: string; pathname: string; hash: string } + toLocation: { href: string; pathname: string; hash: string } + pathChanged: boolean + hrefChanged: boolean + hashChanged: boolean +} + +type Mapping = { + tsEvent: TanStackEventName + // Optional filter — only fire for events matching the predicate (used + // to scope `hashChange*` to hash-only navigations). + filter?: (event: TanStackNavigationEvent) => boolean +} + +const EVENT_MAP: Record = { + // `onBeforeLoad` is closer to Next's `routeChangeStart` semantics than + // `onBeforeNavigate` — both fire after the URL is committed but before + // the page renders. + routeChangeStart: { tsEvent: 'onBeforeLoad' }, + routeChangeComplete: { tsEvent: 'onResolved' }, + // TanStack surfaces errors via router state rather than a dedicated + // lifecycle event. No-op for now; if a consumer needs this we can + // subscribe to `router.__store` instead. + routeChangeError: undefined, + // Next fires `beforeHistoryChange` between `routeChangeStart` and the + // pushState call — `onBeforeNavigate` is the closest TanStack stage. + beforeHistoryChange: { tsEvent: 'onBeforeNavigate' }, + hashChangeStart: { + tsEvent: 'onBeforeNavigate', + filter: (e) => e.hashChanged && !e.pathChanged, + }, + hashChangeComplete: { + tsEvent: 'onResolved', + filter: (e) => e.hashChanged && !e.pathChanged, + }, +} + +type EventsProxy = { + on(event: NextEventName, handler: Handler): void + off(event: NextEventName, handler: Handler): void + emit(event: NextEventName, ...args: unknown[]): void +} + +const proxyCache = new WeakMap() + +function createProxy(router: AnyRouter): EventsProxy { + // Tracked per (event, handler) so the same handler can subscribe to + // multiple Next events with independent unsubscribes. + const unsubs = new Map void>>() + + return { + on(event, handler) { + const mapping = EVENT_MAP[event] + if (!mapping) return + + const adapt = (e: TanStackNavigationEvent) => { + if (mapping.filter && !mapping.filter(e)) return + // Next handlers expect the destination URL string + a shallow + // flag. We don't model shallow routing under TanStack, so it's + // always `false`. + handler(e.toLocation.href, { shallow: false }) + } + + const unsub = router.subscribe(mapping.tsEvent, adapt) + let map = unsubs.get(event) + if (!map) { + map = new Map() + unsubs.set(event, map) + } + map.set(handler, unsub) + }, + off(event, handler) { + const map = unsubs.get(event) + if (!map) return + const unsub = map.get(handler) + if (unsub) { + unsub() + map.delete(handler) + } + }, + emit() { + // Next exposes `events.emit` but nothing in studio calls it. + }, + } +} + +export function getRouterEventsProxy(router: AnyRouter): EventsProxy { + let proxy = proxyCache.get(router) + if (!proxy) { + proxy = createProxy(router) + proxyCache.set(router, proxy) + } + return proxy +} diff --git a/apps/studio/compat/next/api.ts b/apps/studio/compat/next/api.ts new file mode 100644 index 00000000000..15e46eac39f --- /dev/null +++ b/apps/studio/compat/next/api.ts @@ -0,0 +1,370 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +// Adapter that lets Next.js pages-router API handlers run under TanStack +// Start server routes without rewriting them. The adapter builds a +// NextApiRequest-shaped object from the Web `Request`, plus a +// NextApiResponse proxy that captures `status` / `setHeader` / `json` / +// `send` / `write` / `end` / `redirect` and produces a Web `Response`. +// +// Limitations (acceptable for the bulk of studio API routes): +// - Body is buffered: streaming handlers that want to flush to the client +// before finishing will still buffer in memory. Studio has 2 such routes +// (functions body.ts, mcp/index.ts) that need bespoke handling. +// - Body parsing covers JSON and urlencoded. Multipart/raw binary inbound +// is not implemented — no studio route reads multipart in. + +type NextHandler = (req: NextApiRequest, res: NextApiResponse) => unknown | Promise + +interface RouteCtx { + request: Request + params?: Record +} + +export function toWebHandler(handler: NextHandler) { + return async ({ request, params = {} }: RouteCtx): Promise => { + const req = await buildRequest(request, params) + const { res, finalize } = buildResponse() + + const result = await handler(req, res) + + // Handlers like /api/ai/docs.ts already return a Web Response directly + // (they were written for the edge runtime). Pass it through. + if (result instanceof Response) return result + + return finalize() + } +} + +async function buildRequest( + request: Request, + params: Record +): Promise { + const url = new URL(request.url) + const method = request.method.toUpperCase() + + const headers: Record = {} + request.headers.forEach((value, key) => { + const k = key.toLowerCase() + const existing = headers[k] + if (existing === undefined) { + headers[k] = value + } else if (Array.isArray(existing)) { + existing.push(value) + } else { + headers[k] = [existing, value] + } + }) + + const cookies: Record = {} + const cookieHeader = request.headers.get('cookie') + if (cookieHeader) { + for (const part of cookieHeader.split(';')) { + const trimmed = part.trim() + if (!trimmed) continue + const eq = trimmed.indexOf('=') + const name = eq >= 0 ? trimmed.slice(0, eq) : trimmed + const value = eq >= 0 ? trimmed.slice(eq + 1) : '' + cookies[name] = decodeURIComponent(value) + } + } + + // Next merges route params and URL search params into `req.query`. + // Duplicate keys become arrays (URLSearchParams.getAll). Route params + // take precedence over search params of the same name, so they're + // applied last. + const query: Record = {} + for (const key of new Set(url.searchParams.keys())) { + const values = url.searchParams.getAll(key) + query[key] = values.length === 1 ? values[0] : values + } + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) query[k] = v + } + + let body: unknown = undefined + if (method !== 'GET' && method !== 'HEAD' && request.body) { + const contentType = request.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { + const text = await request.text() + body = text ? JSON.parse(text) : undefined + } else if (contentType.includes('application/x-www-form-urlencoded')) { + const text = await request.text() + body = Object.fromEntries(new URLSearchParams(text)) + } else { + body = await request.text() + } + } + + // Surface a tiny subset of Node's `IncomingMessage` EventEmitter API so + // handlers that wire client-abort via `req.on('close', …)` / + // `req.on('aborted', …)` (e.g. AI streaming routes) can still hook into + // the Web `Request.signal`. We don't model arbitrary event types — only + // `close` and `aborted`, both of which fire when the signal aborts. + const listeners: Record void>> = {} + const fireAbort = () => { + for (const event of ['close', 'aborted'] as const) { + const set = listeners[event] + if (!set) continue + for (const fn of set) { + try { + fn() + } catch { + // Swallow — Node would emit `error` on the request, but we + // have nothing meaningful to do with it here. + } + } + } + } + if (request.signal.aborted) { + queueMicrotask(fireAbort) + } else { + request.signal.addEventListener('abort', fireAbort, { once: true }) + } + + return { + method, + url: url.pathname + url.search, + headers, + query, + body, + cookies, + on(event: string, fn: (...args: unknown[]) => void) { + ;(listeners[event] ??= new Set()).add(fn) + return this + }, + off(event: string, fn: (...args: unknown[]) => void) { + listeners[event]?.delete(fn) + return this + }, + once(event: string, fn: (...args: unknown[]) => void) { + const wrapper = (...args: unknown[]) => { + listeners[event]?.delete(wrapper) + fn(...args) + } + ;(listeners[event] ??= new Set()).add(wrapper) + return this + }, + removeListener(event: string, fn: (...args: unknown[]) => void) { + listeners[event]?.delete(fn) + return this + }, + removeAllListeners(event?: string) { + if (event) listeners[event]?.clear() + else for (const set of Object.values(listeners)) set.clear() + return this + }, + emit(event: string, ...args: unknown[]) { + const set = listeners[event] + if (!set) return false + for (const fn of set) fn(...args) + return true + }, + } as unknown as NextApiRequest +} + +function buildResponse() { + const responseHeaders = new Headers() + const chunks: Uint8Array[] = [] + const encoder = new TextEncoder() + + const encode = (data: unknown): Uint8Array => { + if (data instanceof Uint8Array) return data + if (typeof data === 'string') return encoder.encode(data) + if (data === undefined || data === null) return new Uint8Array(0) + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) { + return new Uint8Array(data) + } + return encoder.encode(String(data)) + } + + // Streaming mode is entered as soon as the handler calls + // `res.writeHead(...)` — that's the Node `ServerResponse` signal that + // headers are committed and chunks should flush as they're written + // (matches what `result.pipeUIMessageStreamToResponse(res, …)` from + // the AI SDK does for token-by-token streaming). + let streamMode = false + let streamController: ReadableStreamDefaultController | null = null + let streamClosed = false + // Backpressure for upstream writers: drain when the controller's + // desired-size goes negative. We don't get a true "drain" event + // from a Web ReadableStream, but most handlers just check the + // return value of `res.write()`. + const stream = new ReadableStream({ + start(controller) { + streamController = controller + }, + cancel() { + streamClosed = true + }, + }) + const enterStreamMode = () => { + if (streamMode) return + streamMode = true + // Flush anything that was buffered before the streaming switch. + for (const c of chunks) streamController?.enqueue(c) + chunks.length = 0 + } + + const res = {} as NextApiResponse & Record + + // `res.statusCode` is the single source of truth so handlers that write + // it directly (a standard Node/Next idiom) are honoured at finalize. + res.statusCode = 200 + res.status = (code: number) => { + res.statusCode = code + return res + } + res.setHeader = (name: string, value: number | string | readonly string[]) => { + const key = name.toLowerCase() + if (Array.isArray(value)) { + responseHeaders.delete(key) + for (const v of value) responseHeaders.append(key, String(v)) + } else { + responseHeaders.set(key, String(value)) + } + return res + } + res.getHeader = (name: string) => responseHeaders.get(name.toLowerCase()) ?? undefined + res.getHeaders = () => { + const out: Record = {} + responseHeaders.forEach((v, k) => { + out[k] = v + }) + return out + } + res.hasHeader = (name: string) => responseHeaders.has(name.toLowerCase()) + res.removeHeader = (name: string) => { + responseHeaders.delete(name.toLowerCase()) + } + // Node `ServerResponse.writeHead(statusCode, statusMessage?, headers?)`. + // We honour `statusCode` and `headers`; `statusMessage` is dropped + // (Fetch `Response` doesn't preserve a custom HTTP/1 reason phrase + // when running through TanStack's runtime). + type WriteHead = ( + code: number, + headersOrMessage?: string | Record, + maybeHeaders?: Record + ) => unknown + ;(res as unknown as { writeHead: WriteHead }).writeHead = ( + code: number, + headersOrMessage?: string | Record, + maybeHeaders?: Record + ) => { + res.statusCode = code + const headers = + typeof headersOrMessage === 'object' && headersOrMessage !== null + ? headersOrMessage + : maybeHeaders + if (headers) { + for (const [name, value] of Object.entries(headers)) { + const key = name.toLowerCase() + if (Array.isArray(value)) { + responseHeaders.delete(key) + for (const v of value) responseHeaders.append(key, String(v)) + } else if (value !== undefined && value !== null) { + responseHeaders.set(key, String(value)) + } + } + } + enterStreamMode() + return res + } + ;(res as unknown as { flushHeaders: () => unknown }).flushHeaders = () => { + enterStreamMode() + return res + } + res.json = (data: unknown) => { + if (!responseHeaders.has('content-type')) { + responseHeaders.set('content-type', 'application/json') + } + const payload = encode(JSON.stringify(data)) + if (streamMode) streamController?.enqueue(payload) + else chunks.push(payload) + return res + } + res.send = (data: unknown) => { + if (data === undefined || data === null) return res + if ( + typeof data === 'object' && + !(data instanceof Uint8Array) && + !(typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) + ) { + return res.json(data) + } + const payload = encode(data) + if (streamMode) streamController?.enqueue(payload) + else chunks.push(payload) + return res + } + res.write = (chunk: unknown) => { + const payload = encode(chunk) + if (streamMode) streamController?.enqueue(payload) + else chunks.push(payload) + return true + } + res.end = (data?: unknown) => { + if (streamMode) { + if (data !== undefined && data !== null) streamController?.enqueue(encode(data)) + if (!streamClosed) { + try { + streamController?.close() + } catch { + // Already closed (or never opened): nothing to do. + } + streamClosed = true + } + } else if (data !== undefined) { + chunks.push(encode(data)) + } + return res + } + res.redirect = (...args: unknown[]) => { + const [status, location] = + typeof args[0] === 'number' ? [args[0], args[1] as string] : [302, args[0] as string] + res.statusCode = status + responseHeaders.set('location', location) + return res + } + + // Node `ServerResponse` is a Writable EventEmitter. AI SDK / other + // pipe-style helpers may attach listeners (drain/close/error) for + // backpressure or cleanup. Accept and no-op them so they don't + // crash; abort is already handled at the `req` level via the + // Request.signal subscription. + const noopEE = (...args: unknown[]) => { + void args + return res + } + ;(res as unknown as Record).on = noopEE + ;(res as unknown as Record).once = noopEE + ;(res as unknown as Record).off = noopEE + ;(res as unknown as Record).removeListener = noopEE + ;(res as unknown as Record).removeAllListeners = noopEE + ;(res as unknown as Record).emit = () => false + ;(res as unknown as Record).addListener = noopEE + ;(res as unknown as Record).prependListener = noopEE + ;(res as unknown as { headersSent: boolean }).headersSent = false + ;(res as unknown as { writableEnded: boolean }).writableEnded = false + + const finalize = (): Response => { + if (streamMode) { + // The handler entered streaming mode (writeHead/flushHeaders) and + // may still be pushing chunks asynchronously after this return — + // the stream stays open until res.end() runs. + return new Response(stream, { status: res.statusCode, headers: responseHeaders }) + } + const totalLen = chunks.reduce((n, c) => n + c.length, 0) + const payload = new Uint8Array(totalLen) + let offset = 0 + for (const c of chunks) { + payload.set(c, offset) + offset += c.length + } + return new Response(payload.length === 0 ? null : payload, { + status: res.statusCode, + headers: responseHeaders, + }) + } + + return { res, finalize } +} diff --git a/apps/studio/compat/next/compat/router.ts b/apps/studio/compat/next/compat/router.ts new file mode 100644 index 00000000000..18ff9c9f1f6 --- /dev/null +++ b/apps/studio/compat/next/compat/router.ts @@ -0,0 +1,8 @@ +// Next exports a `next/compat/router` variant whose `useRouter()` returns +// `NextRouter | null` so that components can be rendered outside a +// pages-router context without crashing. Under TanStack we always have a +// router, so this never returns null in practice. Re-export the full +// pages-router shim so the surface stays in sync (back, forward, reload, +// prefetch, isReady, locale, etc.). + +export { useRouter } from '../router' diff --git a/apps/studio/compat/next/dynamic.tsx b/apps/studio/compat/next/dynamic.tsx new file mode 100644 index 00000000000..430b60ca521 --- /dev/null +++ b/apps/studio/compat/next/dynamic.tsx @@ -0,0 +1,58 @@ +import { lazy, Suspense, type ComponentType, type ReactNode } from 'react' + +type DynamicOptions = { + loading?: () => ReactNode + ssr?: boolean + // Accepted-and-ignored: legacy/deprecated Next options. Listed so + // call sites that pass them don't fail TypeScript. + suspense?: boolean + loadableGenerated?: unknown +} + +type Loader

= () => Promise> | Promise<{ default: ComponentType

}> + +type DynamicComponent

= ComponentType

& { + // Next stamps a `.preload()` on the returned component so consumers + // can trigger the loader ahead of render (e.g. on hover). Returns a + // promise that resolves when the loader settles. + preload: () => Promise +} + +function isDefaultExport

( + value: ComponentType

| { default: ComponentType

} +): value is { default: ComponentType

} { + return typeof value === 'object' && value !== null && 'default' in value +} + +// eslint-disable-next-line no-restricted-exports +export default function dynamic

( + loader: Loader

, + options: DynamicOptions = {} +): DynamicComponent

{ + const { loading, ssr = true } = options + + // Cache the in-flight loader promise so calling `preload()` and then + // rendering doesn't kick off a second import. Matches Next's behaviour + // where preload is essentially a head-start on the same module load. + let cached: Promise<{ default: ComponentType

}> | null = null + const load = () => { + if (cached) return cached + cached = loader().then((mod) => (isDefaultExport

(mod) ? mod : { default: mod })) + return cached + } + + const Lazy = lazy(load) + + function DynamicComponent(props: P) { + if (ssr === false && typeof window === 'undefined') return null + return ( + + + + ) + } + + ;(DynamicComponent as DynamicComponent

).preload = () => load().then(() => undefined) + + return DynamicComponent as DynamicComponent

+} diff --git a/apps/studio/compat/next/head.tsx b/apps/studio/compat/next/head.tsx new file mode 100644 index 00000000000..b5e79e6e73a --- /dev/null +++ b/apps/studio/compat/next/head.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' + +// Next/Head's job is to inject children into the document `` and +// deduplicate them by `key` prop. React 19 ships native "document +// metadata" hoisting: any ``, `<meta>`, `<link>`, `<style>`, or +// `<script>` rendered anywhere in the tree is hoisted to `<head>` +// automatically and works for both client render and SSR. Studio uses +// `<Head>` exclusively for those elements (`<title>`, `<meta>`, +// `<link>` — see `pages/maintenance.tsx`, `pages/claim-project.tsx`, +// etc.), so the shim can be a passthrough that lets React do the +// hoisting. +// +// Trade-offs vs the previous `createPortal(children, document.head)` +// approach: +// - SSR: the portal returned `null` on the server, so head content +// from `<Head>` was never in the prerendered HTML. Native hoisting +// emits the metadata in the prerendered output. +// - Deduplication: React 19 dedupes `<title>` (last one wins, same +// as `document.title`) and merges `<meta>`/`<link>` by their +// attributes. Next dedupes by an explicit `key` prop. The +// observable output is equivalent for the consumer set we have. +// - Non-metadata children: React 19 only hoists the metadata tags +// listed above. If a consumer ever renders an arbitrary element +// inside `<Head>`, it'll render in place rather than in `<head>`. +// We have no such consumers today; if one shows up, swap to a +// portal+SSR-collector setup. + +// eslint-disable-next-line no-restricted-exports +export default function Head({ children }: { children?: ReactNode }) { + return <>{children}</> +} diff --git a/apps/studio/compat/next/image.tsx b/apps/studio/compat/next/image.tsx new file mode 100644 index 00000000000..c0a931a7041 --- /dev/null +++ b/apps/studio/compat/next/image.tsx @@ -0,0 +1,159 @@ +import { + forwardRef, + useEffect, + useRef, + type ComponentPropsWithoutRef, + type CSSProperties, + type ForwardedRef, + type SyntheticEvent, +} from 'react' + +import { BASE_PATH } from '@/lib/constants' + +// Next/Image is a smart wrapper around `<img>` that handles automatic +// resizing, lazy loading, blur placeholders, and a CDN loader. Under +// Vite we don't run the Next image optimizer, so this shim degrades to +// a plain `<img>` while preserving the prop surface so consumer code +// compiles without modification. +// +// basePath: Next auto-prepends the configured basePath to absolute `src` +// values that point at app-served assets (the optimizer URL Next builds +// internally is itself prefixed). Our shim has no optimizer, so we +// prepend basePath directly on the rendered <img src> for absolute +// paths. Full URLs (http:, data:, etc.) and already-prefixed paths are +// left alone. When a custom `loader` is provided the loader is +// responsible for the final URL — Next behaves the same way. + +type ImageLoaderProps = { src: string; width: number; quality?: number } +type ImageLoader = (props: ImageLoaderProps) => string + +interface ImageProps extends Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt' | 'loading'> { + src: string | { src: string; width?: number; height?: number } + alt: string + width?: number | `${number}` + height?: number | `${number}` + fill?: boolean + sizes?: string + priority?: boolean + loading?: 'lazy' | 'eager' + quality?: number | `${number}` + loader?: ImageLoader + placeholder?: 'blur' | 'empty' | `data:image/${string}` + blurDataURL?: string + unoptimized?: boolean + onLoadingComplete?: (img: HTMLImageElement) => void +} + +function applyBasePath(src: string): string { + if (!BASE_PATH) return src + // Schemes (http:, https:, data:, blob:) and protocol-relative URLs. + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(src) || src.startsWith('//')) return src + // Already prefixed — happens when callers manually prepend BASE_PATH + // (several studio components do this today). + if (src === BASE_PATH || src.startsWith(`${BASE_PATH}/`)) return src + // Absolute app path — prepend. + if (src.startsWith('/')) return `${BASE_PATH}${src}` + // Relative path — leave alone. + return src +} + +function resolveSrc( + src: ImageProps['src'], + width?: ImageProps['width'], + quality?: ImageProps['quality'], + loader?: ImageLoader +): string { + const raw = typeof src === 'string' ? src : src.src + if (loader) { + // Loader is the source of truth for the final URL — match Next and + // don't auto-prepend basePath. The loader receives the original src. + return loader({ + src: raw, + width: typeof width === 'number' ? width : Number(width ?? 0), + quality: quality !== undefined ? Number(quality) : undefined, + }) + } + return applyBasePath(raw) +} + +const Image = forwardRef(function Image( + { + src, + width, + height, + fill, + sizes, + priority, + loading, + quality, + loader, + placeholder: _placeholder, + blurDataURL: _blurDataURL, + unoptimized: _unoptimized, + onLoad, + onLoadingComplete, + style, + ...rest + }: ImageProps, + forwardedRef: ForwardedRef<HTMLImageElement> +) { + const innerRef = useRef<HTMLImageElement | null>(null) + + // Keep the latest callback in a ref so firing doesn't depend on the + // caller memoizing onLoadingComplete. + const onLoadingCompleteRef = useRef(onLoadingComplete) + useEffect(() => { + onLoadingCompleteRef.current = onLoadingComplete + }) + + const resolvedSrc = resolveSrc(src, width, quality, loader) + + // Mirror Next's onLoadingComplete, firing at most once per resolved src. + const firedForSrc = useRef<string | null>(null) + const fireLoadingComplete = (img: HTMLImageElement) => { + if (firedForSrc.current === resolvedSrc) return + firedForSrc.current = resolvedSrc + onLoadingCompleteRef.current?.(img) + } + + const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => { + onLoad?.(e) + if (e.currentTarget) fireLoadingComplete(e.currentTarget) + } + + // If the image is already cached (loaded synchronously before our + // handler attaches), fire on mount so the contract holds. Keyed on src + // so it re-arms when the image changes. + useEffect(() => { + const img = innerRef.current + if (img?.complete && img.naturalWidth > 0) fireLoadingComplete(img) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resolvedSrc]) + + const finalStyle: CSSProperties | undefined = fill + ? { position: 'absolute', inset: 0, width: '100%', height: '100%', ...style } + : style + + return ( + <img + {...rest} + ref={(node) => { + innerRef.current = node + if (typeof forwardedRef === 'function') forwardedRef(node) + else if (forwardedRef) forwardedRef.current = node + }} + src={resolvedSrc} + width={fill ? undefined : width} + height={fill ? undefined : height} + sizes={sizes} + // Match Next's defaults: lazy unless priority/loading says otherwise. + loading={loading ?? (priority ? 'eager' : 'lazy')} + fetchPriority={priority ? 'high' : rest.fetchPriority} + style={finalStyle} + onLoad={handleLoad} + /> + ) +}) + +// eslint-disable-next-line no-restricted-exports +export default Image diff --git a/apps/studio/compat/next/legacy/image.tsx b/apps/studio/compat/next/legacy/image.tsx new file mode 100644 index 00000000000..da45e196ca3 --- /dev/null +++ b/apps/studio/compat/next/legacy/image.tsx @@ -0,0 +1,163 @@ +import { + forwardRef, + useEffect, + useRef, + type ComponentPropsWithoutRef, + type CSSProperties, + type ForwardedRef, + type SyntheticEvent, +} from 'react' + +import { BASE_PATH } from '@/lib/constants' + +// `next/legacy/image` is the pre-Next-13 Image API. Functionally similar +// to `next/image` but with `layout` and `objectFit`/`objectPosition` +// props instead of `fill` + style. Same shim approach: degrade to a +// plain <img> with the prop surface preserved. +// +// basePath: see the matching note in ../image.tsx — absolute `src` +// values are auto-prefixed with the configured basePath; full URLs and +// already-prefixed paths pass through; loader-produced URLs are not +// touched. + +type ImageLoaderProps = { src: string; width: number; quality?: number } +type ImageLoader = (props: ImageLoaderProps) => string + +type Layout = 'fill' | 'fixed' | 'intrinsic' | 'responsive' +type ObjectFit = CSSProperties['objectFit'] + +interface ImageProps extends Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'alt' | 'loading'> { + src: string | { src: string } + alt: string + width?: number | `${number}` + height?: number | `${number}` + layout?: Layout + objectFit?: ObjectFit + objectPosition?: CSSProperties['objectPosition'] + priority?: boolean + loading?: 'lazy' | 'eager' + quality?: number | `${number}` + loader?: ImageLoader + placeholder?: 'blur' | 'empty' + blurDataURL?: string + unoptimized?: boolean + sizes?: string + onLoadingComplete?: (img: HTMLImageElement) => void +} + +function applyBasePath(src: string): string { + if (!BASE_PATH) return src + // Schemes (http:, https:, data:, blob:) and protocol-relative URLs. + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(src) || src.startsWith('//')) return src + if (src === BASE_PATH || src.startsWith(`${BASE_PATH}/`)) return src + if (src.startsWith('/')) return `${BASE_PATH}${src}` + return src +} + +function resolveSrc( + src: ImageProps['src'], + width?: ImageProps['width'], + quality?: ImageProps['quality'], + loader?: ImageLoader +): string { + const raw = typeof src === 'string' ? src : src.src + if (loader) { + return loader({ + src: raw, + width: typeof width === 'number' ? width : Number(width ?? 0), + quality: quality !== undefined ? Number(quality) : undefined, + }) + } + return applyBasePath(raw) +} + +const Image = forwardRef(function Image( + { + src, + width, + height, + layout, + objectFit, + objectPosition, + priority, + loading, + quality, + loader, + placeholder: _placeholder, + blurDataURL: _blurDataURL, + unoptimized: _unoptimized, + sizes, + onLoad, + onLoadingComplete, + style, + ...rest + }: ImageProps, + forwardedRef: ForwardedRef<HTMLImageElement> +) { + const innerRef = useRef<HTMLImageElement | null>(null) + + // Keep the latest callback in a ref so firing doesn't depend on the + // caller memoizing onLoadingComplete. + const onLoadingCompleteRef = useRef(onLoadingComplete) + useEffect(() => { + onLoadingCompleteRef.current = onLoadingComplete + }) + + const resolvedSrc = resolveSrc(src, width, quality, loader) + + // Fire onLoadingComplete at most once per resolved src, matching Next's + // once-per-load contract. + const firedForSrc = useRef<string | null>(null) + const fireLoadingComplete = (img: HTMLImageElement) => { + if (firedForSrc.current === resolvedSrc) return + firedForSrc.current = resolvedSrc + onLoadingCompleteRef.current?.(img) + } + + const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => { + onLoad?.(e) + if (e.currentTarget) fireLoadingComplete(e.currentTarget) + } + + // Catch the cached-image case where the load event fired before our + // handler attached. Keyed on src so it re-arms when the image changes. + useEffect(() => { + const img = innerRef.current + if (img?.complete && img.naturalWidth > 0) fireLoadingComplete(img) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resolvedSrc]) + + const layoutStyle: CSSProperties | undefined = + layout === 'fill' + ? { position: 'absolute', inset: 0, width: '100%', height: '100%' } + : layout === 'responsive' + ? { width: '100%', height: 'auto' } + : undefined + + const finalStyle: CSSProperties | undefined = + layoutStyle || objectFit || objectPosition || style + ? { ...layoutStyle, objectFit, objectPosition, ...style } + : undefined + + return ( + <img + {...rest} + ref={(node) => { + innerRef.current = node + if (typeof forwardedRef === 'function') forwardedRef(node) + else if (forwardedRef) forwardedRef.current = node + }} + src={resolvedSrc} + width={layout === 'fill' ? undefined : width} + height={layout === 'fill' ? undefined : height} + sizes={sizes} + loading={loading ?? (priority ? 'eager' : 'lazy')} + fetchPriority={priority ? 'high' : rest.fetchPriority} + style={finalStyle} + onLoad={handleLoad} + /> + ) +}) + +// eslint-disable-next-line no-restricted-exports +export default Image diff --git a/apps/studio/compat/next/link.tsx b/apps/studio/compat/next/link.tsx new file mode 100644 index 00000000000..c662e623466 --- /dev/null +++ b/apps/studio/compat/next/link.tsx @@ -0,0 +1,252 @@ +import { Link as TanStackLink } from '@tanstack/react-router' +import { + cloneElement, + forwardRef, + isValidElement, + type AnchorHTMLAttributes, + type ForwardedRef, + type MouseEvent, + type ReactElement, + type ReactNode, + type Ref, +} from 'react' + +// Next's Link accepts either a string `href` or a `UrlObject` +// ({pathname, query, hash}). Workspace source does both — flatten +// `UrlObject` into `pathname?search#hash` first so the TanStack `to` +// prop (which only takes a string) works for either input. + +type QueryValue = string | number | boolean | string[] | undefined | null +type UrlObject = { + pathname?: string + query?: Record<string, QueryValue> | string + hash?: string + search?: string +} + +interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> { + href: string | UrlObject + children?: ReactNode + replace?: boolean + scroll?: boolean + shallow?: boolean + passHref?: boolean + prefetch?: boolean | null | 'auto' + locale?: string | false + legacyBehavior?: boolean + // Next-specific props with no TanStack equivalent; accepted and dropped. + as?: string | UrlObject +} + +function serializeQuery(query: UrlObject['query']): string { + if (!query) return '' + if (typeof query === 'string') return query.startsWith('?') ? query : `?${query}` + const params = new URLSearchParams() + for (const [key, raw] of Object.entries(query)) { + if (raw == null) continue + if (Array.isArray(raw)) { + for (const item of raw) if (item != null) params.append(key, String(item)) + } else { + params.set(key, String(raw)) + } + } + const s = params.toString() + return s ? `?${s}` : '' +} + +function resolveHref(href: string | UrlObject): string { + if (typeof href === 'string') return href + const pathname = href.pathname ?? '' + const search = href.search ?? serializeQuery(href.query) + const hash = href.hash ? (href.hash.startsWith('#') ? href.hash : `#${href.hash}`) : '' + return `${pathname}${search}${hash}` +} + +// Inlined at build time via Vite's `define`. Must agree with Vite `base` +// and `tanstackStart({ router: { basepath } })`. Empty string when no +// basePath is configured. Used to strip a duplicate prefix in +// `splitInternalUrl` below — see the comment there. +const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' + +// TanStack Link's `to` prop is a route-pattern path; query params and hash +// must be passed separately via `search` / `hash`. Studio code (and Next's +// own contract) routinely passes one of three href shapes: +// 1. a relative path like `/project/abc/editor/123?schema=public` +// 2. a same-origin absolute URL produced by `new URL(...).toString()`, +// e.g. `http://localhost:8082/project/abc/editor/123?schema=public` +// (this is what `buildTableEditorUrl` does) +// 3. a genuinely external URL like `https://supabase.com/docs`. +// +// If we forward any of these straight through to TanStack as `to`, TanStack +// either fails to match a known route pattern (#1 with query) or treats +// the whole thing as external (#2) and falls back to native browser +// navigation — which the user sees as a full page reload. +// +// Split into three parts: pathname, search, hash. Same-origin absolute +// URLs are normalised to a relative path. Cross-origin URLs are left +// alone so TanStack's external-link path handles them. +// +// basePath quirk: TanStack's `to` is **basepath-relative** — given +// `basepath: '/dashboard'` and `to: '/foo'`, TanStack builds the href +// `/dashboard/foo`. Next's contract treats `href` as the **full path +// from app root including basePath**, and studio code routinely +// pre-prefixes BASE_PATH (e.g. `buildTableEditorUrl` calls +// `new URL(`${BASE_PATH}/project/.../editor/...`, location.origin)`). +// Forwarding the BASE_PATH-prefixed pathname as `to` makes TanStack +// double-prefix it (`/dashboard/dashboard/project/...`). Strip the +// basePath when we see it, so what we hand TanStack is always +// basepath-relative. +function splitInternalUrl(url: string): { + to: string + search?: Record<string, string> + hash?: string +} { + // Try to detect cross-origin absolute URLs cheaply before paying for a + // full parse. Protocol-relative URLs (`//host/...`) are always external. + if (url.startsWith('//')) { + return { to: url } + } + + // Use the document origin as the parse base so relative inputs resolve. + // SSR has no `location`; fall back to a placeholder host that won't ever + // collide with a real one. + const base = + typeof window !== 'undefined' && window.location ? window.location.origin : 'http://_/' + + let parsed: URL + try { + parsed = new URL(url, base) + } catch { + return { to: url } + } + + // Cross-origin → leave for TanStack to handle as external. + if ( + typeof window !== 'undefined' && + window.location && + parsed.origin !== window.location.origin + ) { + return { to: url } + } + + let pathname = parsed.pathname + // Strip a leading basePath segment so we hand TanStack a basepath- + // relative path. Match `/dashboard` exactly OR `/dashboard/...`; don't + // strip a coincidental prefix like `/dashboard-other`. + if ( + NEXT_PUBLIC_BASE_PATH && + (pathname === NEXT_PUBLIC_BASE_PATH || pathname.startsWith(`${NEXT_PUBLIC_BASE_PATH}/`)) + ) { + pathname = pathname.slice(NEXT_PUBLIC_BASE_PATH.length) || '/' + } + + const search = Object.fromEntries(parsed.searchParams) + const hash = parsed.hash || undefined + return { + to: pathname, + search: Object.keys(search).length > 0 ? search : undefined, + hash, + } +} + +// Next: prefetch=true|"auto" → eagerly preload; false → never; default +// in production = true. TanStack's preload values are "intent" (on hover/ +// focus), "viewport" (when entering viewport), "render" (immediately), +// or false. "intent" is the closest behavioural match to Next's default +// hover-prefetch behaviour. +function mapPrefetch(prefetch: LinkProps['prefetch']): 'intent' | false | undefined { + if (prefetch === false) return false + if (prefetch === true || prefetch === 'auto') return 'intent' + return undefined +} + +const Link = forwardRef(function Link( + { + href, + as: _as, + replace, + scroll: _scroll, + shallow: _shallow, + passHref, + prefetch, + locale: _locale, + legacyBehavior, + children, + onClick, + ...rest + }: LinkProps, + ref: ForwardedRef<HTMLAnchorElement> +) { + const resolved = resolveHref(href) + const { to, search, hash } = splitInternalUrl(resolved) + const preload = mapPrefetch(prefetch) + + // Next's `legacyBehavior` (and `passHref` with a custom anchor child) + // expects the consumer's child element to *be* the anchor — the parent + // <Link> shouldn't render its own. We approximate by cloning the + // single child element and stamping `href` plus a click handler that + // fires TanStack navigation. Modifier-clicks / middle-clicks fall + // through to the browser the same way Next does. + if (legacyBehavior && isValidElement(children)) { + const child = children as ReactElement<{ + href?: string + onClick?: (e: MouseEvent<HTMLAnchorElement>) => void + ref?: Ref<HTMLAnchorElement> + }> + return ( + <TanStackLink + // eslint-disable-next-line @typescript-eslint/no-explicit-any + to={to as any} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + search={search as any} + hash={hash} + replace={replace} + preload={preload} + // TanStack Link supports a function child for custom rendering. + // Cast through unknown to bridge the typing — runtime contract is + // the same. + > + {(() => { + // Inside TanStackLink's child slot we still get an <a> by default; + // when legacyBehavior is true the consumer wants to control the + // anchor themselves. Render the cloned child with merged props. + return cloneElement(child, { + href: resolved, + ref, + onClick: (e: MouseEvent<HTMLAnchorElement>) => { + child.props.onClick?.(e) + onClick?.(e) + }, + }) + })()} + </TanStackLink> + ) + } + + // `passHref` without `legacyBehavior` is a no-op in modern Next when + // the child is anything other than an `<a>` — the wrapping <Link> + // renders the anchor and the `href` attribute lands on it. We get the + // same outcome by passing children through TanStackLink, which also + // renders an anchor. + void passHref + + return ( + <TanStackLink + // eslint-disable-next-line @typescript-eslint/no-explicit-any + to={to as any} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + search={search as any} + hash={hash} + replace={replace} + preload={preload} + ref={ref} + onClick={onClick} + {...rest} + > + {children} + </TanStackLink> + ) +}) + +// eslint-disable-next-line no-restricted-exports +export default Link diff --git a/apps/studio/compat/next/navigation.ts b/apps/studio/compat/next/navigation.ts new file mode 100644 index 00000000000..576693a1d9b --- /dev/null +++ b/apps/studio/compat/next/navigation.ts @@ -0,0 +1,140 @@ +import { + useLocation, + useMatches, + useParams as useTanStackParams, + useRouter as useTanStackRouter, +} from '@tanstack/react-router' +import { useMemo } from 'react' + +// `next/navigation` is the App Router hook surface (Next 13+). Studio is +// still pages-based, so most of these are entry-points that arrive via +// stray imports or shared packages — we keep the surface comprehensive +// to avoid call sites silently getting `undefined` for something Next +// would have provided. + +type NavigateOptions = { + scroll?: boolean +} + +type PrefetchOptions = { + kind?: 'auto' | 'full' | 'temporary' +} + +export function usePathname(): string | null { + return useLocation().pathname +} + +export function useRouter() { + const router = useTanStackRouter() + + return useMemo( + () => ({ + push: (href: string, _options?: NavigateOptions) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.navigate({ to: href as any }) + }, + replace: (href: string, _options?: NavigateOptions) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.navigate({ to: href as any, replace: true }) + }, + // App Router's refresh() refetches the current route's data without + // re-mounting the React tree. Closest TanStack equivalent is + // invalidating the active matches. + refresh: () => { + router.invalidate() + }, + back: () => { + if (typeof window !== 'undefined') window.history.back() + }, + forward: () => { + if (typeof window !== 'undefined') window.history.forward() + }, + prefetch: (href: string, _options?: PrefetchOptions) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.preloadRoute({ to: href as any }).catch(() => { + // Match Next's fire-and-forget contract. + }) + }, + }), + [router] + ) +} + +export function useSearchParams(): URLSearchParams { + const location = useLocation() + // App Router returns a ReadonlyURLSearchParams; URLSearchParams is + // structurally compatible for read access. + return useMemo(() => new URLSearchParams(location.searchStr ?? ''), [location.searchStr]) +} + +export function useParams< + T extends Record<string, string | string[]> = Record<string, string>, +>(): T { + // App Router's useParams() returns a flat object of dynamic segments. + // TanStack's strict-false useParams returns the same shape merged + // across matches. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return useTanStackParams({ strict: false } as any) as T +} + +// `useSelectedLayoutSegment(parallelRoute?)` returns the active leaf +// segment one level below the layout that calls it. We approximate by +// returning the trailing URL segment of the active route, which is what +// most studio callers want when they drift into this hook. +export function useSelectedLayoutSegment(_parallelRouteKey?: string): string | null { + const matches = useMatches() + const leafRouteId = matches[matches.length - 1]?.routeId + if (!leafRouteId) return null + const segments = leafRouteId.split('/').filter(Boolean) + const last = segments[segments.length - 1] + return last ?? null +} + +export function useSelectedLayoutSegments(_parallelRouteKey?: string): string[] { + const matches = useMatches() + const leafRouteId = matches[matches.length - 1]?.routeId + if (!leafRouteId) return [] + return leafRouteId.split('/').filter(Boolean) +} + +// Next's RedirectType enum. App Router uses these as string values. +export const RedirectType = { + push: 'push', + replace: 'replace', +} as const +export type RedirectType = (typeof RedirectType)[keyof typeof RedirectType] + +// Next signals these by throwing internal symbols that Next's renderer +// catches. We have no equivalent renderer in TanStack, so we use the +// closest behavioural match: `redirect` does a client-side navigation +// and then throws so the calling component stops rendering on the same +// tick (mirroring Next's "function never returns" contract). Callers +// that catch and ignore will get unexpected behaviour — same as in Next. +const NEXT_NOT_FOUND = Symbol.for('next.not-found') +const NEXT_REDIRECT = Symbol.for('next.redirect') + +export function notFound(): never { + const err = new Error('NEXT_NOT_FOUND') as Error & { digest?: symbol } + err.digest = NEXT_NOT_FOUND + throw err +} + +export function redirect(url: string, type: RedirectType = RedirectType.replace): never { + if (typeof window !== 'undefined') { + if (type === RedirectType.push) { + window.location.assign(url) + } else { + window.location.replace(url) + } + } + const err = new Error(`NEXT_REDIRECT;${type};${url}`) as Error & { digest?: symbol } + err.digest = NEXT_REDIRECT + throw err +} + +export function permanentRedirect(url: string, type: RedirectType = RedirectType.replace): never { + // Next distinguishes permanent vs temporary at the framework level + // (different HTTP status when triggered server-side). Client-side + // there's no observable difference; we delegate to redirect(). + return redirect(url, type) +} diff --git a/apps/studio/compat/next/router.test.ts b/apps/studio/compat/next/router.test.ts new file mode 100644 index 00000000000..f5b3aa67323 --- /dev/null +++ b/apps/studio/compat/next/router.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { resolveUrl } from './router' + +describe('resolveUrl (next/router compat shim)', () => { + it('returns string URLs untouched', () => { + expect(resolveUrl('/project/abc/editor/1?schema=public')).toBe( + '/project/abc/editor/1?schema=public' + ) + // A string that happens to contain brackets is NOT interpolated. + expect(resolveUrl('/project/[ref]/editor')).toBe('/project/[ref]/editor') + }) + + it('interpolates dynamic params from query into a bracketed pathname and drops them from the query string', () => { + // The regression we fixed: useUrlState pushes `router.pathname` (the bracketed + // pattern) + query. Next fills the params; the shim must too, or TanStack + // navigates to a literal `/project/[ref]/...` and bounces to "project not found". + expect( + resolveUrl({ + pathname: '/project/[ref]/database/tables/[id]', + query: { ref: 'default', id: '123', schema: 'public', sort: 'name:asc' }, + }) + ).toBe('/project/default/database/tables/123?schema=public&sort=name%3Aasc') + }) + + it('omits the query string entirely when every param is consumed', () => { + expect(resolveUrl({ pathname: '/project/[ref]/settings', query: { ref: 'abc' } })).toBe( + '/project/abc/settings' + ) + }) + + it('leaves a bracket-free pathname and its query alone', () => { + expect(resolveUrl({ pathname: '/project/default/sql', query: { a: '1', b: '2' } })).toBe( + '/project/default/sql?a=1&b=2' + ) + }) + + it('interpolates required and optional catch-all segments', () => { + expect( + resolveUrl({ + pathname: '/project/[ref]/logs/[[...slug]]', + query: { ref: 'r', slug: ['a', 'b'] }, + }) + ).toBe('/project/r/logs/a/b') + expect(resolveUrl({ pathname: '/p/[...rest]', query: { rest: ['x', 'y'] } })).toBe('/p/x/y') + }) + + it('does not interpolate when query is a raw string (cannot fill named params)', () => { + expect(resolveUrl({ pathname: '/project/[ref]/x', query: 'a=1' })).toBe('/project/[ref]/x?a=1') + }) + + it('preserves hash and honours an explicit search override', () => { + expect(resolveUrl({ pathname: '/project/[ref]', query: { ref: 'x' }, hash: 'section' })).toBe( + '/project/x#section' + ) + expect( + resolveUrl({ pathname: '/project/[ref]', query: { ref: 'x', drop: 'me' }, search: '?a=1' }) + ).toBe('/project/x?a=1') + }) +}) diff --git a/apps/studio/compat/next/router.ts b/apps/studio/compat/next/router.ts new file mode 100644 index 00000000000..479b4234e84 --- /dev/null +++ b/apps/studio/compat/next/router.ts @@ -0,0 +1,406 @@ +import { + useLocation, + useMatches, + useParams, + useSearch, + useRouter as useTanStackRouter, +} from '@tanstack/react-router' +import { useMemo } from 'react' + +import { getRouterEventsProxy } from './_router-events' + +// Next's pages-router exposes `router.pathname` as the route *pattern* +// (e.g. `/project/[ref]/sql/[id]`), not the resolved URL. TanStack's +// route id uses `$param` — convert so legacy code that does +// `router.pathname.endsWith('/sql/[id]')` keeps working. +// +// Also strip the trailing slash TanStack appends to index-route ids +// (`/project/$ref/`). Next's pages-router never includes a trailing +// slash, so consumers like `router.pathname.split('/')[3]` (used in +// the project sidebar's active-route check) silently see an empty +// string for index pages instead of `undefined`, and the home icon +// stops highlighting. The root path stays `/` either way. +function toNextPathPattern(routeId: string) { + // Strip TanStack's layout-route segments — they're prefixed with `_` + // (`_app`, `_auth`, etc.) and don't appear in the URL or in Next's + // `router.pathname`. Without this, downstream code that derives a path + // segment from `pathname.split('/')[N]` indexes into the wrong slot — + // e.g. Sidebar uses index 3 to pick the active route, expecting + // `/org/[slug]/general` but receiving `/_app/org/[slug]/general` and + // ending up with `[slug]` instead of `general`. + const withoutLayoutSegments = routeId.replace(/\/_[a-zA-Z0-9_]+(?=\/|$)/g, '') + const withBracketParams = withoutLayoutSegments.replace(/\$([a-zA-Z0-9_]+)/g, '[$1]') + if (withBracketParams === '' || withBracketParams === '/') return '/' + return withBracketParams.replace(/\/$/, '') +} + +// Normalise TanStack's `router.basepath` to Next's `router.basePath` +// shape: '' for "no basePath" or '/path' for "configured" (leading +// slash, no trailing slash). +function toNextBasePath(tanstackBasepath: string | undefined): string { + if (!tanstackBasepath || tanstackBasepath === '/') return '' + const withLeading = tanstackBasepath.startsWith('/') ? tanstackBasepath : `/${tanstackBasepath}` + return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading +} + +type QueryValue = string | number | boolean | string[] | undefined | null +type UrlObject = { + pathname?: string + query?: Record<string, QueryValue> | string + hash?: string + search?: string +} + +function serializeQuery(query: UrlObject['query']): string { + if (!query) return '' + if (typeof query === 'string') return query.startsWith('?') ? query : `?${query}` + const params = new URLSearchParams() + for (const [key, raw] of Object.entries(query)) { + if (raw == null) continue + if (Array.isArray(raw)) { + for (const item of raw) if (item != null) params.append(key, String(item)) + } else { + params.set(key, String(raw)) + } + } + const s = params.toString() + return s ? `?${s}` : '' +} + +// Next's pages-router fills dynamic segments in a UrlObject's `pathname` from +// `query`, then drops the consumed keys from the query string — e.g. +// `push({ pathname: '/project/[ref]/editor/[id]', query: { ref, id, foo } })` +// resolves to `/project/<ref>/editor/<id>?foo=...`. `router.pathname` here is +// the bracketed route *pattern* (see toNextPathPattern) and callers like +// useUrlState push it back verbatim, so without this a sort/filter update on a +// dynamic route would navigate TanStack to a LITERAL `/project/[ref]/...`, +// which matches no project (ref === '[ref]') and bounces to a "project not +// found" redirect. Mirrors Next's behaviour so those pushes stay on-page. +function interpolatePathname( + pathname: string, + query: Record<string, QueryValue> +): { pathname: string; query: Record<string, QueryValue> } { + if (!pathname.includes('[')) return { pathname, query } + const consumed = new Set<string>() + const encodeValue = (v: QueryValue) => + v == null + ? '' + : Array.isArray(v) + ? v.map((item) => encodeURIComponent(String(item))).join('/') + : encodeURIComponent(String(v)) + const interpolated = pathname + // optional + required catch-all: `[[...name]]` / `[...name]` + .replace(/\[\[?\.\.\.([^\]]+)\]?\]/g, (_match, name: string) => { + consumed.add(name) + return encodeValue(query[name]) + }) + // single dynamic segment: `[name]` + .replace(/\[([^\]]+)\]/g, (_match, name: string) => { + consumed.add(name) + return encodeValue(query[name]) + }) + if (consumed.size === 0) return { pathname: interpolated, query } + const rest: Record<string, QueryValue> = {} + for (const [key, value] of Object.entries(query)) { + if (!consumed.has(key)) rest[key] = value + } + return { pathname: interpolated, query: rest } +} + +// Exported for unit tests (see router.test.ts) — not part of the Next surface. +export function resolveUrl(url: string | UrlObject): string { + if (typeof url === 'string') return url + let pathname = url.pathname ?? '' + let query = url.query + // Interpolate named params into the path when query is a record — a raw query + // string can't fill `[param]` placeholders, so leave it untouched. + if (query && typeof query === 'object') { + const interpolated = interpolatePathname(pathname, query) + pathname = interpolated.pathname + query = interpolated.query + } + const search = url.search ?? serializeQuery(query) + const hash = url.hash ? (url.hash.startsWith('#') ? url.hash : `#${url.hash}`) : '' + return `${pathname}${search}${hash}` +} + +// Studio code occasionally constructs `router.push` targets via +// `new URL().toString()` (e.g. `buildTableEditorUrl`), producing fully +// qualified `http://localhost:8082/dashboard/project/.../editor/123?...` +// strings — origin + basePath + path. Next's router tolerated both by +// treating same-origin absolute URLs as relative paths AND understanding +// basePath was already in the input. +// +// TanStack Router needs `to` to be basepath-relative — given +// `basepath: '/dashboard'` and `to: '/foo'`, it produces `/dashboard/foo`. +// So we strip the origin AND the basePath when present; otherwise +// `router.push('/dashboard/...')` would double-prefix to +// `/dashboard/dashboard/...`. Mirrors the equivalent logic in the +// next/link shim's `splitInternalUrl`. Cross-origin URLs pass through +// untouched so TanStack hands them to the browser as external. +const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' + +// Strip a leading basePath segment from a path-shape URL (no origin). +// Mirrors what Next's pages-router does for `asPath`. Used by both +// `useRouter().asPath` and the push/replace path-normalisation pipeline. +function stripBasePath(pathish: string): string { + if (!NEXT_PUBLIC_BASE_PATH) return pathish + if (pathish === NEXT_PUBLIC_BASE_PATH) return '/' + if (pathish.startsWith(`${NEXT_PUBLIC_BASE_PATH}/`)) { + return pathish.slice(NEXT_PUBLIC_BASE_PATH.length) + } + return pathish +} + +function toRelativeSameOrigin(url: string): string { + let pathname: string + let search = '' + let hash = '' + if (url.startsWith('http://') || url.startsWith('https://')) { + if (typeof window === 'undefined' || !window.location) return url + try { + const parsed = new URL(url) + if (parsed.origin !== window.location.origin) return url + pathname = parsed.pathname + search = parsed.search + hash = parsed.hash + } catch { + return url + } + } else { + // Relative input — split on the first `?` / `#` so we can strip a + // basePath segment from the pathname only. + const queryIdx = url.indexOf('?') + const hashIdx = url.indexOf('#') + const splitIdx = + [queryIdx, hashIdx].filter((i) => i >= 0).sort((a, b) => a - b)[0] ?? url.length + pathname = url.slice(0, splitIdx) + const rest = url.slice(splitIdx) + const qEnd = rest.indexOf('#') + if (rest.startsWith('?')) { + search = qEnd >= 0 ? rest.slice(0, qEnd) : rest + hash = qEnd >= 0 ? rest.slice(qEnd) : '' + } else if (rest.startsWith('#')) { + hash = rest + } + } + return `${stripBasePath(pathname)}${search}${hash}` +} + +// Next's pages-router passes a TransitionOptions bag as the 3rd arg to +// push/replace. We accept the shape but ignore every field — TanStack has +// no direct equivalent for any of them (shallow, locale, scroll, +// unstable_skipClientCache). Notably `shallow` is a no-op here, NOT a +// push-vs-replace signal: callers pass `push(url, as, { shallow: true })` +// expecting a normal history push (e.g. useUrlState, MonacoEditor). Whether +// a navigation replaces is decided solely by which method is called +// (push vs replace) via the internal `_replace` flag below. +type TransitionOptions = { + shallow?: boolean + locale?: string | false + scroll?: boolean + unstable_skipClientCache?: boolean +} + +type PrefetchOptions = { + priority?: boolean + locale?: string | false + unstable_skipClientCache?: boolean +} + +export function useRouter() { + const router = useTanStackRouter() + const location = useLocation() + const matches = useMatches() + const params = useParams({ strict: false }) + const search = useSearch({ strict: false }) + + return useMemo(() => { + const leafRouteId = matches[matches.length - 1]?.routeId ?? location.pathname + const pathPattern = toNextPathPattern(leafRouteId) + + // Both push and replace accept Next's (url, as?, options?) signature. + // `as` is the legacy alias path (mostly obsolete in modern Next; ignored + // here — the resolved `url` is what we navigate to). Returns + // Promise<boolean> matching Next; TanStack's navigate doesn't surface + // a success boolean so we always resolve to true. + const navigate = async ( + url: string | UrlObject, + _as?: string | UrlObject, + // `_replace` is an internal flag set by the `replace()` method below. + // It's intentionally not part of Next's public TransitionOptions. + options?: TransitionOptions & { _replace?: boolean } + ): Promise<boolean> => { + const to = toRelativeSameOrigin(resolveUrl(url)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await router.navigate({ to: to as any, replace: options?._replace }) + return true + } + + return { + // ---- state ---- + pathname: pathPattern, + // Next's pages-router exposes `route` and `pathname` as the same value + // — the route pattern with bracketed dynamic segments. Some studio + // code (e.g. AppLayout/BranchLink, AppLayout/ProjectDropdown) reads + // `router.route` specifically; without this it's `undefined` and + // downstream `.split('/')` calls crash. + route: pathPattern, + // Route params take precedence over search params of the same name, + // matching Next's pages-router req.query merge order. + query: { ...search, ...params }, + // Next's pages-router `asPath` is path + query + hash *without* the + // origin and *without* the configured `basePath` + // (https://nextjs.org/docs/pages/api-reference/functions/use-router). + // Studio code relies on the no-basePath shape — e.g. + // OrganizationSettingsLayout compares `currentPath === '/org/<slug>/ + // general'` for the side-nav active state, with section hrefs that + // never include `/dashboard`. Returning a basepath-prefixed value + // breaks every such strict-equality check. + asPath: stripBasePath(location.href), + // Mirror Next's pages-router contract for `basePath`: + // - no basePath configured → '' (empty string) + // - configured → '/dashboard' (leading slash, no trailing) + // + // TanStack stores the raw `basepath` option without normalising: + // `undefined` becomes '/' (its internal default), 'dashboard' stays + // 'dashboard', '/dashboard/' stays '/dashboard/'. Studio code then + // does `${router.basePath}/img/...` and trips on every non-Next + // shape ('/' → '//img/...' protocol-relative; 'dashboard' → + // 'dashboard/img/...' relative-to-current-path; '/dashboard/' → + // '/dashboard//img/...' double slash). + basePath: toNextBasePath(router.basepath), + // TanStack resolves params/search synchronously on render, so the + // pages-router "is the dynamic param ready yet?" flag is always + // true here. (In Next this can be false during the very first + // render of a dynamic page.) + isReady: true, + // No equivalent under TanStack — surface as static `false` so call + // sites that read these don't crash. Next-only features. + isFallback: false, + isPreview: false, + isLocaleDomain: false, + // i18n routing isn't wired through TanStack here. Return undefined + // for the active locale and an empty list for the rest — matches + // a Next app that has no i18n config. + locale: undefined as string | undefined, + locales: undefined as string[] | undefined, + defaultLocale: undefined as string | undefined, + domainLocales: undefined as Array<{ domain: string; defaultLocale: string }> | undefined, + + // ---- navigation ---- + push: (url: string | UrlObject, as?: string | UrlObject, options?: TransitionOptions) => + navigate(url, as, options), + replace: (url: string | UrlObject, as?: string | UrlObject, options?: TransitionOptions) => + navigate(url, as, { ...options, _replace: true }), + reload: () => { + if (typeof window !== 'undefined') window.location.reload() + }, + back: () => { + if (typeof window !== 'undefined') window.history.back() + }, + forward: () => { + if (typeof window !== 'undefined') window.history.forward() + }, + prefetch: async ( + url: string, + _asPath?: string, + _options?: PrefetchOptions + ): Promise<void> => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await router.preloadRoute({ to: url as any }) + } catch { + // Next's prefetch is fire-and-forget; swallow resolution errors + // (e.g. unknown route) so callers don't have to guard. + } + }, + // Next-only escape hatch for popstate handling. Not wired up; accept + // and discard the callback so call sites compile and run without + // throwing. Callers that *rely* on this (none currently in studio) + // would need a real implementation. + beforePopState: (_cb: (state: unknown) => boolean) => {}, + + // ---- events ---- + events: getRouterEventsProxy(router), + } + }, [router, location.href, location.pathname, matches, params, search]) +} + +// Normalise an optional-catch-all route's params across both frameworks. +// +// Next's `[[...name]]` surfaces the trailing path as `query.name: string[]`, +// while TanStack's splat (`$`) surfaces it as `query._splat: string`. The +// shim can't rename `_splat` to the Next param name on its own — that name +// only exists in the Next page filename and never reaches the router — so +// the caller passes it. Returns the trailing path as a string[] plus the +// remaining query params (with the catch-all keys stripped) for building +// query strings. Shared by every migrated catch-all page so the logic lives +// in one place. +export function parseCatchAllRoute( + query: Record<string, string | string[] | undefined>, + paramName: string +): { + segments: string[] | undefined + queryParams: Record<string, string | string[] | undefined> +} { + const { [paramName]: raw, _splat, ...queryParams } = query + const segments = Array.isArray(raw) + ? raw + : typeof _splat === 'string' && _splat + ? _splat.split('/') + : undefined + return { segments, queryParams } +} + +// Module-scope singleton — Next exposes the same proxy via +// `import router from 'next/router'`. We have one consumer +// (Support/DiscordCTACard) that reads `router.basePath` at render time +// outside of a hook context, so we surface the env-derived basePath +// directly. Push/replace/etc. fall through to `window.location` to keep +// future module-scope navigations safe; nothing in studio uses them +// today. +const singletonBasePath = toNextBasePath( + // Read both the TanStack and Next env names — TanStack also reads + // VITE_BASE_URL but the studio config writes NEXT_PUBLIC_BASE_PATH. + process.env.NEXT_PUBLIC_BASE_PATH +) + +const singletonRouter = { + basePath: singletonBasePath, + pathname: '', + route: '', + query: {} as Record<string, string | string[] | undefined>, + asPath: '', + isReady: true, + isFallback: false, + isPreview: false, + isLocaleDomain: false, + push: async (url: string | UrlObject): Promise<boolean> => { + if (typeof window !== 'undefined') window.location.assign(resolveUrl(url)) + return true + }, + replace: async (url: string | UrlObject): Promise<boolean> => { + if (typeof window !== 'undefined') window.location.replace(resolveUrl(url)) + return true + }, + reload: () => { + if (typeof window !== 'undefined') window.location.reload() + }, + back: () => { + if (typeof window !== 'undefined') window.history.back() + }, + forward: () => { + if (typeof window !== 'undefined') window.history.forward() + }, + prefetch: async () => {}, + beforePopState: (_cb: (state: unknown) => boolean) => {}, + events: { + on: () => {}, + off: () => {}, + emit: () => {}, + }, +} + +// eslint-disable-next-line no-restricted-exports +export default singletonRouter diff --git a/apps/studio/compat/next/script.tsx b/apps/studio/compat/next/script.tsx new file mode 100644 index 00000000000..150211de310 --- /dev/null +++ b/apps/studio/compat/next/script.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react' + +// Next/Script handles ordering, deduplication, and load callbacks for +// third-party scripts. Under Vite we have no orchestrator — render a +// `<script>` and approximate the callback contract. Studio doesn't +// currently mount any <Script>, but the surface is shimmed +// comprehensively so the migration doesn't catch anyone out later. + +type Strategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker' + +interface ScriptProps extends Omit< + ComponentPropsWithoutRef<'script'>, + 'children' | 'onLoad' | 'onError' +> { + // Accepted-and-dropped in this shim — the browser handles network + // priority via the regular `<script>` element; we don't reorder. + strategy?: Strategy + // Children as the script body (inline scripts) are also accepted via + // dangerouslySetInnerHTML; Next allows either. We honour both. + children?: ReactNode + onLoad?: (e: Event) => void + onReady?: () => void + onError?: (e: Event | string) => void +} + +// eslint-disable-next-line no-restricted-exports +export default function Script({ + strategy: _strategy, + children, + dangerouslySetInnerHTML, + onLoad, + onError, + onReady, + src, + id, + ...rest +}: ScriptProps) { + const ref = useRef<HTMLScriptElement | null>(null) + const readyFiredRef = useRef(false) + + // onReady fires once the script has loaded (or immediately on mount + // if it's already been loaded by a previous instance with the same + // id). Approximate by firing once after mount when the element is + // present and either has no src (inline) or has finished loading. + useEffect(() => { + const node = ref.current + if (!node || !onReady || readyFiredRef.current) return + if (!src) { + // Inline script — body executed synchronously on mount. + readyFiredRef.current = true + onReady() + return + } + if (node.dataset.loaded === 'true') { + readyFiredRef.current = true + onReady() + } + }, [onReady, src]) + + const handleLoad = (e: Event | React.SyntheticEvent<HTMLScriptElement>) => { + const node = ref.current + if (node) node.dataset.loaded = 'true' + onLoad?.(e as Event) + if (onReady && !readyFiredRef.current) { + readyFiredRef.current = true + onReady() + } + } + + const handleError = (e: Event | React.SyntheticEvent<HTMLScriptElement>) => { + onError?.(e as Event) + } + + // For inline scripts, `children` is preferred over + // dangerouslySetInnerHTML when both are present (matches Next). + if (children !== undefined) { + return ( + <script + {...rest} + id={id} + ref={ref} + onLoad={handleLoad} + onError={handleError} + dangerouslySetInnerHTML={{ + __html: typeof children === 'string' ? children : '', + }} + /> + ) + } + + return ( + // This shim mirrors next/script for the TanStack build (where vite aliases + // `next/script` to this file via nextCompat). Call sites that opt for an + // explicit `async`/`defer` already pass it through via {...rest}; we don't + // force one here because Next's <Script> doesn't either at this layer. + // eslint-disable-next-line @next/next/no-sync-scripts + <script + {...rest} + id={id} + src={src} + ref={ref} + onLoad={handleLoad} + onError={handleError} + dangerouslySetInnerHTML={dangerouslySetInnerHTML} + /> + ) +} diff --git a/apps/studio/compat/next/server.ts b/apps/studio/compat/next/server.ts new file mode 100644 index 00000000000..a966c9b37d4 --- /dev/null +++ b/apps/studio/compat/next/server.ts @@ -0,0 +1,77 @@ +// Vite-side compat for `next/server`. Studio's middleware (proxy.ts) and +// a couple of App Router routes import from here. Most of this surface +// isn't actually exercised at runtime under TanStack/Vite — middleware +// doesn't run, and the App Router routes only call NextResponse.json — +// but the imports need to resolve to real values so the bundle compiles +// and consumers don't crash if they're invoked. + +type NextInit = ResponseInit & { + // Next attaches a `request` bag on `next()` to allow middleware to + // mutate request headers before forwarding. Accepted-and-ignored here. + request?: { headers?: HeadersInit } +} + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) + +function buildResponse(body: BodyInit | null, init?: NextInit): Response { + // Strip the Next-only `request` field before handing off to the + // standard Response constructor — it'd be retained as a non-standard + // option and trip strict implementations. + if (init && 'request' in init) { + const { request: _request, ...rest } = init + return new Response(body, rest) + } + return new Response(body, init) +} + +export const NextResponse = { + json: (data: unknown, init?: NextInit) => { + // Response.json sets Content-Type for us; merge any caller headers. + return Response.json(data, init && 'request' in init ? { ...init, request: undefined } : init) + }, + + // Middleware uses `NextResponse.next()` to signal "continue without + // rewriting". Under TanStack we have no middleware runtime, so the + // returned Response is effectively a placeholder that mirrors the + // shape Next produces (empty 200). + next: (init?: NextInit): Response => buildResponse(null, init), + + redirect: (url: string | URL, init?: number | NextInit): Response => { + const status = typeof init === 'number' ? init : (init?.status ?? 307) + if (!REDIRECT_STATUSES.has(status)) { + throw new RangeError( + `[next/server compat] NextResponse.redirect: invalid status ${status}; expected one of ${[...REDIRECT_STATUSES].join(', ')}` + ) + } + const headers = new Headers(typeof init === 'object' ? init?.headers : undefined) + headers.set('Location', String(url)) + return new Response(null, { status, headers }) + }, + + // Used in middleware to rewrite an incoming request to a different + // path without changing the visible URL. Encoded by setting the + // `x-middleware-rewrite` header (matches Next's runtime contract). + rewrite: (destination: string | URL, init?: NextInit): Response => { + const headers = new Headers(init?.headers) + headers.set('x-middleware-rewrite', String(destination)) + return buildResponse(null, { ...init, headers }) + }, + + error: (): Response => new Response(null, { status: 500 }), +} + +// NextRequest extends Request with `nextUrl`, `cookies`, `geo`, and `ip`. +// None of our workspace source reads those fields at runtime — most +// imports are type-only. Aliasing the runtime value to the global +// Request constructor keeps value imports working (e.g. `import { +// NextRequest } from 'next/server'` followed by `(req: NextRequest) => …` +// type annotations under verbatimModuleSyntax / isolatedModules). +export const NextRequest = Request +export type NextRequest = Request + +// Stub the type-only exports Next ships so consumers importing them +// don't need to special-case the shim. +export type NextMiddleware = (request: Request) => Response | Promise<Response> | undefined | void +export type MiddlewareConfig = { + matcher?: string | string[] +} diff --git a/apps/studio/components/grid/MsSqlValidation.tsx b/apps/studio/components/grid/MsSqlValidation.tsx index 8a57748fd64..8a5a92fff57 100644 --- a/apps/studio/components/grid/MsSqlValidation.tsx +++ b/apps/studio/components/grid/MsSqlValidation.tsx @@ -1,5 +1,5 @@ import type { ComponentType, ReactNode } from 'react' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import type { Filter, Sort } from './types' import { isMsSqlForeignTable, type Entity } from '@/data/table-editor/table-editor-types' diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index 4c7871006de..e1f3e5205f8 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -91,6 +91,7 @@ export const SupabaseGrid = ({ customHeader={customHeader} isRefetching={isRefetching} tableQueriesEnabled={tableQueriesEnabled} + rows={rows} /> {msSqlWarning.warning !== null && <msSqlWarning.Component />} diff --git a/apps/studio/components/grid/components/common/DefaultValue.tsx b/apps/studio/components/grid/components/common/DefaultValue.tsx index 6a94b026097..bf46676a977 100644 --- a/apps/studio/components/grid/components/common/DefaultValue.tsx +++ b/apps/studio/components/grid/components/common/DefaultValue.tsx @@ -1,3 +1,3 @@ export const DefaultValue = () => { - return <span className="sb-grid-default-value opacity-50">DEFAULT</span> + return <span className="opacity-50">DEFAULT</span> } diff --git a/apps/studio/components/grid/components/common/DropdownControl.tsx b/apps/studio/components/grid/components/common/DropdownControl.tsx index fe49c3ccc45..ab498bf368c 100644 --- a/apps/studio/components/grid/components/common/DropdownControl.tsx +++ b/apps/studio/components/grid/components/common/DropdownControl.tsx @@ -30,8 +30,8 @@ export const DropdownControl = ({ <DropdownMenu> <DropdownMenuTrigger className={className}>{children}</DropdownMenuTrigger> <DropdownMenuContent side={side} align={align}> - <div className="dropdown-control" style={{ maxHeight: '30vh' }}> - {options.length === 0 && <p className="dropdown-control__empty-text">No more items</p>} + <div className="overflow-auto" style={{ maxHeight: '30vh' }}> + {options.length === 0 && <p>No more items</p>} {options.map((x) => { return ( <DropdownMenuItemTooltip diff --git a/apps/studio/components/grid/components/common/EmptyValue.tsx b/apps/studio/components/grid/components/common/EmptyValue.tsx index b1196a5c556..f4172a3f7f2 100644 --- a/apps/studio/components/grid/components/common/EmptyValue.tsx +++ b/apps/studio/components/grid/components/common/EmptyValue.tsx @@ -1,3 +1,3 @@ export const EmptyValue = () => { - return <span className="sb-grid-empty-value opacity-50">EMPTY</span> + return <span className="block opacity-50">EMPTY</span> } diff --git a/apps/studio/components/grid/components/common/NullValue.tsx b/apps/studio/components/grid/components/common/NullValue.tsx index a2f45c9398c..c9cafa35019 100644 --- a/apps/studio/components/grid/components/common/NullValue.tsx +++ b/apps/studio/components/grid/components/common/NullValue.tsx @@ -1,3 +1,3 @@ export const NullValue = () => { - return <span className="null-value opacity-50">NULL</span> + return <span className="block opacity-50">NULL</span> } diff --git a/apps/studio/components/grid/components/editor/DateTimeEditor.tsx b/apps/studio/components/grid/components/editor/DateTimeEditor.tsx index d0133a7f547..741bd38c464 100644 --- a/apps/studio/components/grid/components/editor/DateTimeEditor.tsx +++ b/apps/studio/components/grid/components/editor/DateTimeEditor.tsx @@ -13,8 +13,8 @@ import { PopoverContent, PopoverTrigger, } from 'ui' -import { TimestampInfo, timestampLocalFormatter } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' +import { TimestampInfo, timestampLocalFormatter } from 'ui-patterns/TimestampInfo' import { BlockKeys } from '../common/BlockKeys' import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' diff --git a/apps/studio/components/grid/components/editor/JsonEditor.tsx b/apps/studio/components/grid/components/editor/JsonEditor.tsx index 21c6224df7a..da56dfba3c3 100644 --- a/apps/studio/components/grid/components/editor/JsonEditor.tsx +++ b/apps/studio/components/grid/components/editor/JsonEditor.tsx @@ -165,7 +165,7 @@ export const JsonEditor = <TRow, TSummaryRow = unknown>({ <div className={`${ !!value && jsonString.trim().length == 0 ? 'sb-grid-fill-container' : '' - } sb-grid-json-editor__trigger`} + } text-grid overflow-hidden text-ellipsis px-2`} onClick={() => setIsPopoverOpen(!isPopoverOpen)} > {value === null || value === '' ? <NullValue /> : jsonString} diff --git a/apps/studio/components/grid/components/editor/NumberEditor.tsx b/apps/studio/components/grid/components/editor/NumberEditor.tsx index f61f087a032..3d5ed3866f1 100644 --- a/apps/studio/components/grid/components/editor/NumberEditor.tsx +++ b/apps/studio/components/grid/components/editor/NumberEditor.tsx @@ -25,7 +25,7 @@ export function NumberEditor<TRow, TSummaryRow = unknown>({ return ( <input - className="sb-grid-number-editor" + className="h-full w-full px-2" ref={autoFocusAndSelect} value={value ?? ''} onChange={onChange} diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index a3931a74654..f049c1df4a0 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -119,7 +119,7 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({ <div className={cn( !!value && value.toString().trim().length === 0 && 'sb-grid-fill-container', - 'sb-grid-text-editor__trigger' + 'text-grid overflow-hidden text-ellipsis px-2' )} onClick={() => setIsPopoverOpen(!isPopoverOpen)} > diff --git a/apps/studio/components/grid/components/editor/TimeEditor.tsx b/apps/studio/components/grid/components/editor/TimeEditor.tsx index b092ccd2b81..59cfcfdbd2a 100644 --- a/apps/studio/components/grid/components/editor/TimeEditor.tsx +++ b/apps/studio/components/grid/components/editor/TimeEditor.tsx @@ -42,7 +42,7 @@ function BaseEditor<TRow, TSummaryRow = unknown>({ return ( <input - className="sb-grid-time-editor" + className="h-full w-full px-2" ref={autoFocusAndSelect} defaultValue={value ?? ''} onChange={onChange} diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index ae41b4119c7..49933019828 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -2,7 +2,7 @@ import { THRESHOLD_COUNT } from '@supabase/pg-meta' import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { AlertCircle, ArrowLeft, ArrowRight, HelpCircle, Loader2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -190,6 +190,17 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = // eslint-disable-next-line react-hooks/exhaustive-deps }, [isError, snap.enforceExactCount, error?.code]) + // Reset back to the first page whenever the filters change + const hasMountedRef = useRef(false) + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true + return + } + snap.setPage(1) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters]) + // [Joshen] One to revisit if we can consolidate this and the main return statement if (isForeignTableSelected) { return ( diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index 50e8bb81595..afced4818d5 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -56,8 +56,8 @@ export const ForeignKeyFormatter = (props: Props) => { foreignKeyColumn?.format === 'bytea' && !!value ? convertByteaToHex(value) : value return ( - <div className="sb-grid-foreign-key-formatter flex justify-between"> - <span className="sb-grid-foreign-key-formatter__text"> + <div className="flex w-full items-center justify-between flex justify-between"> + <span className="m-0 grow overflow-hidden text-ellipsis"> {formattedValue === null ? <NullValue /> : formattedValue} </span> {isLoading && formattedValue !== null && ( @@ -86,6 +86,7 @@ export const ForeignKeyFormatter = (props: Props) => { </PopoverTrigger> <PopoverContent align="end" + collisionPadding={8} className="p-0 w-96" onDoubleClick={(e) => { e.preventDefault() diff --git a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx index f45858e7ca5..f72d3ce51b5 100644 --- a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx +++ b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx @@ -122,39 +122,41 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee </span> : </p> - <DataGrid - className="h-32 rounded-b border-0" - columns={columns} - rows={rows} - onSelectedCellChange={(args: { - column: CalculatedColumn<SupaRow, unknown> - rowIdx: number - row: SupaRow - }) => { - selectedCellRef.current = { idx: args.column.idx, rowIdx: args.rowIdx } - }} - onCellDoubleClick={(_, e) => { - e.preventDefault() - e.stopPropagation() - }} - renderers={{ - noRowsFallback: ( - <div className="w-96 px-2"> - {isLoading && ( - <div className="py-2"> - <ShimmeringLoader /> - </div> - )} - {isError && ( - <p className="text-foreground-light"> - Failed to find referencing row: {error.message} - </p> - )} - {isSuccess && <p className="text-foreground-light">No results were returned</p>} - </div> - ), - }} - /> + <div className="h-32 overflow-hidden"> + <DataGrid + className="h-full rounded-b border-0" + columns={columns} + rows={rows} + onSelectedCellChange={(args: { + column: CalculatedColumn<SupaRow, unknown> + rowIdx: number + row: SupaRow + }) => { + selectedCellRef.current = { idx: args.column.idx, rowIdx: args.rowIdx } + }} + onCellDoubleClick={(_, e) => { + e.preventDefault() + e.stopPropagation() + }} + renderers={{ + noRowsFallback: ( + <div className="w-96 px-2"> + {isLoading && ( + <div className="py-2"> + <ShimmeringLoader /> + </div> + )} + {isError && ( + <p className="text-foreground-light"> + Failed to find referencing row: {error.message} + </p> + )} + {isSuccess && <p className="text-foreground-light">No results were returned</p>} + </div> + ), + }} + /> + </div> <div className="flex items-center justify-end px-2 py-1"> <EditorTablePageLink href={`/project/${ref}/editor/${table.id}?schema=${table.schema}&filter=${column}%3Aeq%3A${value}`} diff --git a/apps/studio/components/grid/components/grid/ColumnHeader.tsx b/apps/studio/components/grid/components/grid/ColumnHeader.tsx index cc75818f6f8..d5e528b7c38 100644 --- a/apps/studio/components/grid/components/grid/ColumnHeader.tsx +++ b/apps/studio/components/grid/components/grid/ColumnHeader.tsx @@ -38,7 +38,7 @@ export function ColumnHeader<R>({ } as const return ( - <div ref={setNodeRef} style={style} className="w-full bg-surface-200"> + <div ref={setNodeRef} style={style} className="w-full"> <div className={`sb-grid-column-header ${cursor}`}> <div className="sb-grid-column-header__inner grow" {...attributes} {...listeners}> <ColumnIcon type={columnType} name={column.name as string} foreignKey={foreignKey} /> diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index d5ba2b094b7..9544be6d7ef 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -21,6 +21,7 @@ import { useOnRowsChange } from './Grid.utils' import { GridError } from './GridError' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils' +import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils' import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils' import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' @@ -166,7 +167,7 @@ export const Grid = memo( // Check if this cell has pending changes const isDirty = tableEditorSnap.hasPendingCellChange( snap.table.id, - rowIdentifiers, + getStableRowIdentifiers(row, rowIdentifiers), col.key ) return isDirty ? 'rdg-cell--dirty' : undefined diff --git a/apps/studio/components/grid/components/grid/Grid.utils.tsx b/apps/studio/components/grid/components/grid/Grid.utils.tsx index 8edb10cd8d6..ca19a3fea68 100644 --- a/apps/studio/components/grid/components/grid/Grid.utils.tsx +++ b/apps/studio/components/grid/components/grid/Grid.utils.tsx @@ -3,6 +3,7 @@ import { RowsChangeData } from 'react-data-grid' import { toast } from 'sonner' import { useTableRowOperations } from '../../hooks/useTableRowOperations' +import { getStableRowIdentifiers } from '../../utils/queueOperationUtils' import { SupaRow } from '@/components/grid/types' import { convertByteaToHex } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { DocsButton } from '@/components/ui/DocsButton' @@ -33,7 +34,7 @@ export function useOnRowsChange(rows: SupaRow[]) { const identifiers = {} as Dictionary<any> - isTableLike(snap.originalTable) && + if (isTableLike(snap.originalTable)) { snap.originalTable.primary_keys.forEach((column) => { const col = snap.originalTable.columns.find((c) => c.name === column.name) identifiers[column.name] = @@ -41,8 +42,10 @@ export function useOnRowsChange(rows: SupaRow[]) { ? convertByteaToHex(previousRow[column.name]) : previousRow[column.name] }) + } + const stableIdentifiers = getStableRowIdentifiers(previousRow, identifiers) - if (Object.keys(identifiers).length === 0) { + if (Object.keys(stableIdentifiers).length === 0) { return toast('Unable to update row as table has no primary keys', { description: ( <div> @@ -62,7 +65,7 @@ export function useOnRowsChange(rows: SupaRow[]) { tableId: snap.table.id, table: snap.originalTable, row: previousRow, - rowIdentifiers: identifiers, + rowIdentifiers: stableIdentifiers, columnName: changedColumn, oldValue: previousRow[changedColumn], newValue: rowData[changedColumn], diff --git a/apps/studio/components/grid/components/grid/GridError.tsx b/apps/studio/components/grid/components/grid/GridError.tsx index d18894c8208..955dd170d9b 100644 --- a/apps/studio/components/grid/components/grid/GridError.tsx +++ b/apps/studio/components/grid/components/grid/GridError.tsx @@ -1,13 +1,14 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' +import { ChevronDown } from 'lucide-react' import { useCallback } from 'react' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { isFilterRelatedError } from './GridError.utils' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' import { useTableSort } from '@/components/grid/hooks/useTableSort' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { HighCostError } from '@/components/ui/HighQueryCost' import { InlineLink } from '@/components/ui/InlineLink' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' @@ -53,6 +54,12 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { const isForeignTableMissingVaultKeyError = isForeignTable && error?.message?.includes('query vault failed') + const isIcebergUnauthorizedError = + isForeignTable && + error?.message.includes('iceberg error') && + error?.message.includes('403 Forbidden') && + error?.message.includes('Invalid Compact JWS') + const hasActiveFilters = filters.length > 0 const hasFilterRelatedError = hasActiveFilters && isFilterRelatedError(error?.message) @@ -79,6 +86,8 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { return <FilterError removeAllFilters={removeAllFilters} /> } else if (isInvalidOrderingOperatorError) { return <InvalidOrderingOperatorError error={error} /> + } else if (isIcebergUnauthorizedError) { + return <IcebergUnauthorizedError error={error} /> } return <GeneralError error={error} /> @@ -165,6 +174,30 @@ const InvalidOrderingOperatorError = ({ error }: { error: ResponseError }) => { ) } +const IcebergUnauthorizedError = ({ error }: { error: ResponseError }) => { + const { ref } = useParams() + + return ( + <Admonition + type="warning" + className="pointer-events-auto" + title="Failed to retrieve rows from Iceberg foreign table" + > + <p className="text-balance"> + The API key from your project that's used to retrieve data from your foreign table is either + incorrect or missing. Verify the API key (token) in your{' '} + <InlineLink href={`/project/${ref}/storage/analytics`}>Iceberg Bucket</InlineLink>. + Alternatively, you can also verify the token value in your{' '} + <InlineLink href={`/project/${ref}/integrations/iceberg_wrapper/wrappers`}> + wrapper's settings + </InlineLink>{' '} + or in <InlineLink href={`/project/${ref}/integrations/vault/overview`}>Vault</InlineLink>. + </p> + <ExpandError error={error} /> + </Admonition> + ) +} + const GeneralError = ({ error }: { error: ResponseError }) => { const { filters } = useTableFilter() @@ -183,3 +216,19 @@ const GeneralError = ({ error }: { error: ResponseError }) => { </AlertError> ) } + +const ExpandError = ({ error }: { error: ResponseError }) => { + return ( + <Collapsible> + <CollapsibleTrigger className="mt-2 group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!"> + <div className="flex items-center gap-x-2 w-full cursor-pointer"> + <span className="font-mono uppercase tracking-tight">View error</span> + <ChevronDown className="transition-transform" size={14} /> + </div> + </CollapsibleTrigger> + <CollapsibleContent className="mt-1"> + <code className="text-code-inline">{error.message}</code> + </CollapsibleContent> + </Collapsible> + ) +} diff --git a/apps/studio/components/grid/components/header/ExportDialog.tsx b/apps/studio/components/grid/components/header/ExportDialog.tsx index 727ffb603fa..1c91ff9fa68 100644 --- a/apps/studio/components/grid/components/header/ExportDialog.tsx +++ b/apps/studio/components/grid/components/header/ExportDialog.tsx @@ -15,7 +15,7 @@ import { TabsList_Shadcn_, TabsTrigger_Shadcn_, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { Filter, Sort, SupaTable } from '@/components/grid/types' diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index e264ec62670..88e4e0ff15b 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -21,6 +21,7 @@ import { formatRowsForCSV, hydrateTruncatedRows } from './Header.utils' import { SortPopover } from './sort/SortPopover' import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations' import { useTableSort } from '@/components/grid/hooks/useTableSort' +import type { SupaRow } from '@/components/grid/types' import { GridHeaderActions } from '@/components/interfaces/TableGridEditor/GridHeaderActions' import { isValueTruncated } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils' @@ -46,10 +47,16 @@ import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export type HeaderProps = { customHeader: ReactNode isRefetching: boolean + rows?: SupaRow[] tableQueriesEnabled?: boolean } -export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true }: HeaderProps) => { +export const Header = ({ + customHeader, + isRefetching, + rows, + tableQueriesEnabled = true, +}: HeaderProps) => { useInitializeFiltersFromUrl() useSyncFiltersToUrl() @@ -80,7 +87,7 @@ export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true <div className="flex-1 px-1.5">{customHeader}</div> ) : snap.selectedRows.size > 0 ? ( <div className="flex-1 px-1.5"> - <RowHeader tableQueriesEnabled={tableQueriesEnabled} /> + <RowHeader rows={rows} tableQueriesEnabled={tableQueriesEnabled} /> </div> ) : ( <div @@ -128,10 +135,11 @@ export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true } type RowHeaderProps = { + rows?: SupaRow[] tableQueriesEnabled?: boolean } -const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { +const RowHeader = ({ rows: visibleRows, tableQueriesEnabled = true }: RowHeaderProps) => { const { id: _id } = useParams() const tableId = _id ? Number(_id) : undefined @@ -194,7 +202,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const onRowsDelete = () => { const rowIdxs = Array.from(snap.selectedRows) as number[] - const rows = allRows.filter((x) => rowIdxs.includes(x.idx)) + const rows = (visibleRows ?? allRows).filter((x) => rowIdxs.includes(x.idx)) deleteRows({ rows, diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx index c96808d761f..07a5a4aff64 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx @@ -12,7 +12,7 @@ import { isGroup, SerializableFilterProperty, updateGroupAtPath, -} from 'ui-patterns' +} from 'ui-patterns/FilterBar' import { columnToFilterProperty } from './FilterPopoverNew.utils' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts index 78a28c587e3..2dfff4c7d0b 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts @@ -1,5 +1,5 @@ import { format } from 'date-fns' -import type { FilterProperty, OperatorDefinition } from 'ui-patterns' +import type { FilterProperty, OperatorDefinition } from 'ui-patterns/FilterBar' import type { Filter, SupaColumn } from '@/components/grid/types' import { diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx index 0be00fd5cd4..350898ed730 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -290,7 +290,7 @@ export const SortPopoverPrimitive = ({ asChild variant="dashed" iconRight={<ChevronDown size="14" className="text-foreground-light" />} - className="sb-grid-dropdown__item-trigger" + className="my-1" data-testid="table-editor-pick-column-to-sort-button" > <span>Pick {localSorts.length > 1 ? 'another' : 'a'} column to sort by</span> diff --git a/apps/studio/components/grid/utils/queueConflictResolution.test.ts b/apps/studio/components/grid/utils/queueConflictResolution.test.ts index c8ffab9ac08..00b3bfa6438 100644 --- a/apps/studio/components/grid/utils/queueConflictResolution.test.ts +++ b/apps/studio/components/grid/utils/queueConflictResolution.test.ts @@ -539,6 +539,39 @@ describe('upsertOperation', () => { expect(result.operations[0]).toEqual(otherOp) }) + test('should remove primary key edit when reverted using original row identifiers', () => { + const existingOp: QueuedOperation = { + id: 'edit_cell_content:1:id:id:1', + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + timestamp: Date.now() - 1000, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 1, + newValue: 3, + table: mockTable, + }, + } + + const operations = [existingOp] + const newOperation: NewEditCellContentOperation = { + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 3, + newValue: 1, + table: mockTable, + }, + } + + const result = upsertOperation(operations, newOperation) + + expect(result.operations).toHaveLength(0) + }) + test('should remove operation when number oldValue matches string newValue', () => { const existingOp: QueuedOperation = { id: 'edit_cell_content:1:age:id:1', diff --git a/apps/studio/components/grid/utils/queueOperationUtils.test.ts b/apps/studio/components/grid/utils/queueOperationUtils.test.ts index c250b566f86..325bd18a413 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.test.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.test.ts @@ -1,11 +1,14 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import type { SupaRow } from '../types' import { formatGridDataWithOperationValues, generateTableChangeKey, + getStableRowIdentifiers, + queueRowDeletesWithOptimisticUpdate, rowMatchesIdentifiers, } from './queueOperationUtils' +import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' import { QueuedOperationType, type NewAddRowOperation, @@ -152,6 +155,20 @@ describe('rowMatchesIdentifiers', () => { }) }) +describe('stable queued row identifiers', () => { + test('should use original row identifiers when present', () => { + const row = { idx: 0, id: 3, __originalRowIdentifiers: { id: 1 } } + + expect(getStableRowIdentifiers(row, { id: 3 })).toEqual({ id: 1 }) + }) + + test('should fall back to current identifiers when original row identifiers are not present', () => { + const row = { idx: 0, id: 3 } + + expect(getStableRowIdentifiers(row, { id: 3 })).toEqual({ id: 3 }) + }) +}) + describe('formatGridDataWithOperationValues', () => { const makeRow = (idx: number, data: Record<string, unknown> = {}): SupaRow => ({ idx, @@ -217,7 +234,7 @@ describe('formatGridDataWithOperationValues', () => { }) const result = formatGridDataWithOperationValues({ operations: [op], rows }) - expect(result[0]).toEqual({ idx: 0, id: 1, name: 'Updated' }) + expect(result[0]).toMatchObject({ idx: 0, id: 1, name: 'Updated' }) expect(result[1]).toEqual(rows[1]) }) @@ -448,4 +465,106 @@ describe('formatGridDataWithOperationValues', () => { const result = formatGridDataWithOperationValues({ operations: [op], rows }) expect(result[0].name).toBe('Updated') }) + + test('should preserve row identity after a primary key edit', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' })] + const idEdit = makeEditOp({ + id: 'edit_cell_content:1:id:id:1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 1, + newValue: 3, + table: {} as any, + }, + }) + const nameEdit = makeEditOp({ + id: 'edit_cell_content:1:name:id:1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated Alice', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ operations: [idEdit, nameEdit], rows }) + + expect(result[0]).toMatchObject({ idx: 0, id: 3, name: 'Updated Alice' }) + expect(getStableRowIdentifiers(result[0], { id: result[0].id })).toEqual({ id: 1 }) + }) + + test('should keep two rows distinct when one row takes another row primary key value', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] + const aliceIdEdit = makeEditOp({ + id: 'edit_cell_content:1:id:id:1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 1, + newValue: 3, + table: {} as any, + }, + }) + const bobIdEdit = makeEditOp({ + id: 'edit_cell_content:1:id:id:2', + payload: { + rowIdentifiers: { id: 2 }, + columnName: 'id', + oldValue: 2, + newValue: 1, + table: {} as any, + }, + }) + const bobNameEdit = makeEditOp({ + id: 'edit_cell_content:1:name:id:2', + payload: { + rowIdentifiers: { id: 2 }, + columnName: 'name', + oldValue: 'Bob', + newValue: 'Updated Bob', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ + operations: [aliceIdEdit, bobIdEdit, bobNameEdit], + rows, + }) + + expect(result[0]).toMatchObject({ idx: 0, id: 3, name: 'Alice' }) + expect(getStableRowIdentifiers(result[0], { id: result[0].id })).toEqual({ id: 1 }) + expect(result[1]).toMatchObject({ idx: 1, id: 1, name: 'Updated Bob' }) + expect(getStableRowIdentifiers(result[1], { id: result[1].id })).toEqual({ id: 2 }) + }) +}) + +describe('queueRowDeletesWithOptimisticUpdate', () => { + test('should queue pending add row deletes with the temp row as original row', () => { + const queueOperation = vi.fn() + const row = { idx: -100, __tempId: '-100', name: 'New Row' } as SupaRow + + queueRowDeletesWithOptimisticUpdate({ + rows: [row], + table: { + id: 1, + schema: 'public', + name: 'users', + entity_type: ENTITY_TYPE.TABLE, + primary_keys: [{ name: 'id' }], + } as Parameters<typeof queueRowDeletesWithOptimisticUpdate>[0]['table'], + queueOperation, + projectRef: 'project-ref', + }) + + expect(queueOperation).toHaveBeenCalledWith({ + type: QueuedOperationType.DELETE_ROW, + tableId: 1, + payload: expect.objectContaining({ + rowIdentifiers: { id: undefined, __tempId: '-100' }, + originalRow: row, + }), + }) + }) }) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.ts b/apps/studio/components/grid/utils/queueOperationUtils.ts index 3684886601c..9c19b4328b4 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.ts @@ -1,5 +1,6 @@ import { isPendingAddRow, PendingAddRow, SupaRow } from '../types' import { isTableLike, type Entity } from '@/data/table-editor/table-editor-types' +import { isObject } from '@/lib/helpers' import { EditCellContentOperation, NewQueuedOperation, @@ -8,6 +9,24 @@ import { } from '@/state/table-editor-operation-queue.types' import type { Dictionary } from '@/types' +// Client-only marker that preserves the original SQL WHERE identifiers after PK edits. +const ORIGINAL_ROW_IDENTIFIERS_KEY = '__originalRowIdentifiers' + +export function getStableRowIdentifiers( + row: Dictionary<unknown>, + fallbackIdentifiers: Dictionary<unknown> +): Dictionary<unknown> { + const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY] + return { ...(isObject(identifiers) ? identifiers : fallbackIdentifiers) } +} + +function withOriginalRowIdentifiers<T extends SupaRow>( + row: T, + rowIdentifiers: Dictionary<unknown> +): T { + return { ...row, [ORIGINAL_ROW_IDENTIFIERS_KEY]: { ...rowIdentifiers } } +} + interface EditCellKeyOperation extends Omit< EditCellContentOperation, 'payload' | 'id' | 'timestamp' @@ -59,6 +78,14 @@ export function rowMatchesIdentifiers( return identifierEntries.every(([key, value]) => row[key] === value) } +function rowMatchesOperationIdentifiers( + row: Dictionary<unknown>, + rowIdentifiers: Dictionary<unknown> +): boolean { + const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY] + return rowMatchesIdentifiers(isObject(identifiers) ? identifiers : row, rowIdentifiers) +} + export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary<unknown>): SupaRow[] { return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers)) } @@ -86,8 +113,8 @@ export function queueCellEditWithOptimisticUpdate({ newValue, enumArrayColumns, }: QueueCellEditParams) { - // Updated row identifiers to include __tempId for pending add rows so edits merge into ADD_ROW operation - const rowIdentifiers: Dictionary<unknown> = { ...callerRowIdentifiers } + // Pending add rows use __tempId so edits merge into the ADD_ROW operation. + const rowIdentifiers = getStableRowIdentifiers(row, callerRowIdentifiers) if (isPendingAddRow(row)) { rowIdentifiers.__tempId = row.__tempId } @@ -151,9 +178,14 @@ export const formatGridDataWithOperationValues = ({ operations.forEach((op) => { if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) { const { rowIdentifiers, columnName, newValue } = op.payload - const rowIdx = formattedRows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers)) + const rowIdx = formattedRows.findIndex((row) => + rowMatchesOperationIdentifiers(row, rowIdentifiers) + ) if (rowIdx !== -1) { - formattedRows[rowIdx] = { ...formattedRows[rowIdx], [columnName]: newValue } + formattedRows[rowIdx] = withOriginalRowIdentifiers( + { ...formattedRows[rowIdx], [columnName]: newValue }, + rowIdentifiers + ) } } else if (op.type === QueuedOperationType.ADD_ROW) { const { tempId, rowData } = op.payload @@ -176,9 +208,14 @@ export const formatGridDataWithOperationValues = ({ } } else if (op.type === QueuedOperationType.DELETE_ROW) { const { rowIdentifiers } = op.payload - const rowIdx = formattedRows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers)) + const rowIdx = formattedRows.findIndex((row) => + rowMatchesOperationIdentifiers(row, rowIdentifiers) + ) if (rowIdx !== -1) { - formattedRows[rowIdx] = { ...formattedRows[rowIdx], __isDeleted: true } + formattedRows[rowIdx] = withOriginalRowIdentifiers( + { ...formattedRows[rowIdx], __isDeleted: true }, + rowIdentifiers + ) } } }) @@ -225,12 +262,16 @@ export function queueRowDeletesWithOptimisticUpdate({ table.primary_keys.forEach((pk) => { rowIdentifiers[pk.name] = row[pk.name] }) + const stableRowIdentifiers = getStableRowIdentifiers(row, rowIdentifiers) + if (isPendingAddRow(row)) { + stableRowIdentifiers.__tempId = row.__tempId + } queueOperation({ type: QueuedOperationType.DELETE_ROW, tableId: table.id, payload: { - rowIdentifiers, + rowIdentifiers: stableRowIdentifiers, originalRow: row, table, }, diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx index b667236ecf7..10c8f7576f2 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx @@ -9,7 +9,8 @@ import { TableCell, TableRow, } from 'ui' -import { ShimmeringLoader, TimestampInfo } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { APIKeyDeleteDialog } from './APIKeyDeleteDialog' import { ApiKeyPill } from './ApiKeyPill' diff --git a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx index e3d29980549..fdb6dcb24f8 100644 --- a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx @@ -13,7 +13,8 @@ import { TableHeader, TableRow, } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { APIKeyRow } from './APIKeyRow' import { CreatePublishableAPIKeyDialog } from './CreatePublishableAPIKeyDialog' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx index d5ea3576b33..e18648d1cc6 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx @@ -21,7 +21,7 @@ import { filterAndSortTokens, handleSortChange } from './AccessToken.utils' import { RowLoading } from './AccessTokenTable/RowLoading' import { TableContainer } from './AccessTokenTable/TableContainer' import { ExpiresCell, LastUsedCell, TokenNameCell } from './AccessTokenTable/TokenCells' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useAccessTokenDeleteMutation } from '@/data/access-tokens/access-tokens-delete-mutation' import { AccessToken, useAccessTokensQuery } from '@/data/access-tokens/access-tokens-query' import { useTrack } from '@/lib/telemetry/track' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx index c50888afe70..2046c267ba3 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx @@ -1,7 +1,7 @@ import { X } from 'lucide-react' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { useGroupedPermissions } from '../hooks/useGroupedPermissions' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx index 47972387791..fb289f932ed 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx @@ -24,7 +24,7 @@ import { SelectValue, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index d2b90f9c91d..21cd176dbcd 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -17,7 +17,7 @@ import { SheetHeader, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CUSTOM_EXPIRY_VALUE, diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx index 44c8570e811..a902a92966a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx @@ -22,7 +22,7 @@ import { RowLoading } from '../AccessTokenTable/RowLoading' import { TableContainer } from '../AccessTokenTable/TableContainer' import { ExpiresCell, LastUsedCell, TokenNameCell } from '../AccessTokenTable/TokenCells' import { ViewTokenSheet } from './ViewTokenSheet' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ScopedAccessToken, useScopedAccessTokensQuery, diff --git a/apps/studio/components/interfaces/Account/AuditLogs.tsx b/apps/studio/components/interfaces/Account/AuditLogs.tsx index 017640064cb..0471351a4df 100644 --- a/apps/studio/components/interfaces/Account/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Account/AuditLogs.tsx @@ -12,7 +12,7 @@ import { filterByProjects, sortAuditLogs } from './AuditLogs.utils' import { LogDetailsPanel } from '@/components/interfaces/AuditLogs/LogDetailsPanel' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import Table from '@/components/to-be-cleaned/Table' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { FilterPopover } from '@/components/ui/FilterPopover' import { diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx index 2307ecc7206..2456d57068d 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx @@ -29,6 +29,7 @@ import { } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { parseRedirectMessage } from './AccountIdentities.utils' import { ChangeEmailAddressForm, GitHubChangeEmailAddress, @@ -68,7 +69,7 @@ export const AccountIdentities = () => { const [selectedProviderUpdateEmail, setSelectedProviderUpdateEmail] = useState<string>() const [linkingProviderId, setLinkingProviderId] = useState<string>() - const [, message] = router.asPath.split('#message=') + const message = parseRedirectMessage(router.asPath) const unlinkedExternalProviders = connectableExternalProviders.filter((provider) => { return !identities.some( (identity) => identity.provider === provider.authProvider || identity.provider === provider.id @@ -119,7 +120,7 @@ export const AccountIdentities = () => { } useEffect(() => { - if (message) toast.success(message.replaceAll('+', ' ')) + if (message) toast.success(message) }, [message]) return ( diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts new file mode 100644 index 00000000000..fffe0612f40 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { parseRedirectMessage } from './AccountIdentities.utils' + +describe('parseRedirectMessage', () => { + it('drops the trailing sb marker and decodes + as spaces', () => { + expect( + parseRedirectMessage( + '/account/me#message=Confirmation+link+accepted.+Please+proceed+to+confirm+link+sent+to+the+other+email&sb=' + ) + ).toBe('Confirmation link accepted. Please proceed to confirm link sent to the other email') + }) + + it('returns undefined when there is no hash', () => { + expect(parseRedirectMessage('/account/me')).toBeUndefined() + }) + + it('returns undefined when the fragment has no message key', () => { + expect(parseRedirectMessage('/account/me#sb=')).toBeUndefined() + }) + + it('finds message even when it is not the first fragment param', () => { + expect(parseRedirectMessage('/account/me#sb=&message=Hi+there')).toBe('Hi there') + }) + + it('preserves a literal + via percent-encoding', () => { + expect(parseRedirectMessage('/account/me#message=a%2Bb&sb=')).toBe('a+b') + }) +}) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts new file mode 100644 index 00000000000..842c102db1c --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts @@ -0,0 +1,2 @@ +export const parseRedirectMessage = (asPath: string) => + new URLSearchParams(asPath.split('#')[1] ?? '').get('message') ?? undefined diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx index 455dc84916e..6037e19d3b0 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx @@ -15,7 +15,6 @@ import { SelectValue, Separator, singleThemes, - Theme, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -58,7 +57,7 @@ export const ThemeSettings = () => { value={theme} className="grid grid-cols-2 gap-4" > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( <RadioGroupLargeItem className="p-3 w-full" key={theme.value} diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx index 0eab7e9b764..ea9a8a4df60 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx @@ -1,12 +1,11 @@ import dayjs from 'dayjs' -import { AlertCircle } from 'lucide-react' import { useState } from 'react' -import { Alert, AlertDescription, AlertTitle, Button } from 'ui' +import { Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { AddNewFactorModal } from './AddNewFactorModal' import DeleteFactorModal from './DeleteFactorModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query' import { DATETIME_FORMAT } from '@/lib/constants' @@ -28,17 +27,6 @@ export const TOTPFactors = () => { )} {isSuccess && ( <> - {data.totp.length === 1 && ( - <Alert variant="default" className="mb-2"> - <AlertCircle className="h-4 w-4" /> - <AlertTitle> - We recommend configuring two authenticator apps across different devices - </AlertTitle> - <AlertDescription className="flex flex-col gap-3"> - The two authenticator apps will serve as a backup for each other. - </AlertDescription> - </Alert> - )} <div> {data.totp.map((factor) => { return ( diff --git a/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx b/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx index 5eeb1be51c7..06341d945d4 100644 --- a/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx +++ b/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx @@ -12,15 +12,15 @@ import { CollapsibleContent, CollapsibleTrigger, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { LintInfo } from '../Linter/Linter.constants' import { generateRuleText } from './AdvisorRules.utils' import { CreateRuleSheet } from './CreateRuleSheet' import { DisableRuleModal } from './DisableRuleModal' import { EnableRuleModal } from './EnableRuleModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' import { useLintRuleDeleteMutation } from '@/data/lint/delete-lint-rule-mutation' diff --git a/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx b/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx index 174aa7dc054..ceef2a9f472 100644 --- a/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx +++ b/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx @@ -27,7 +27,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx index 6a3f0da6607..7b396f3884d 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx @@ -1,6 +1,6 @@ import dayjs from 'dayjs' import type { ReactNode } from 'react' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { AuthorizeRequesterDetails, diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx index 76c592f511b..26a0b03bd98 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import type { ReactNode } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' import type { ResourceError } from '@/data/api-authorization/api-authorization-query' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx index 932f837d994..047cc72cf01 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx @@ -14,7 +14,8 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema' import { diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx index aaf83f38c76..df6e42f282e 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import type { ReactNode } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx index a28e51b7e20..26b03f24797 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { Card, CardContent } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' diff --git a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx index 6e410076842..4177044763e 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx @@ -1,9 +1,9 @@ import { cn, CommandList } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' import { TextHighlighter } from 'ui-patterns/CommandMenu' import { CommandMenuGroup } from 'ui-patterns/CommandMenu/internal/CommandMenuGroup' import { CommandMenuItem } from 'ui-patterns/CommandMenu/internal/CommandMenuItem' import type { IActionCommand, IRouteCommand } from 'ui-patterns/CommandMenu/internal/types' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' export interface SearchResult { id: string diff --git a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx index 29e4c0b8898..87fc8c7d932 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx @@ -130,7 +130,7 @@ export function useCreateCommands(options?: CommandOptions) { { id: 'create-rls-policy', name: 'Create RLS Policy', - route: `/project/${ref}/auth/policies?new=true`, + route: `/project/${ref}/database/policies?new=true`, icon: () => <ShieldPlus />, }, ...(IS_PLATFORM diff --git a/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx b/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx index 381f2ef20f0..83858b32941 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx @@ -79,7 +79,7 @@ export function useConfigureOrganizationCommand() { }, ], }, - { deps: [organizations], enabled: !!organizations && organizations.length > 0 } + { deps: [organizations], enabled: IS_PLATFORM && !!organizations && organizations.length > 0 } ) useRegisterCommands( @@ -93,6 +93,6 @@ export function useConfigureOrganizationCommand() { icon: () => <Building />, }, ], - { enabled: !!organizations && organizations.length > 0 } + { enabled: IS_PLATFORM && !!organizations && organizations.length > 0 } ) } diff --git a/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx b/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx index e711dce37d8..89e13a7ef64 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx @@ -141,14 +141,14 @@ export function PolicySearchResults({ query }: PolicySearchResultsProps) { getRoute={(result) => { const policy = policies?.find((p) => String(p.id) === result.id) if (!policy || !projectRef) - return `/project/${projectRef}/auth/policies` as `/${string}` + return `/project/${projectRef}/database/policies` as `/${string}` const params = new URLSearchParams() params.set('edit', String(policy.id)) if (policy.schema) { params.set('schema', policy.schema) } - return `/project/${projectRef}/auth/policies?${params.toString()}` as `/${string}` + return `/project/${projectRef}/database/policies?${params.toString()}` as `/${string}` }} className="pb-9" /> diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index fad16eb4c9c..ebae4de5b4c 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -13,15 +13,18 @@ import { } from 'react' import { useFeaturePreviews } from './useFeaturePreviews' +import { IS_PLATFORM } from '@/lib/constants' import { EMPTY_OBJ } from '@/lib/void' type FeaturePreviewContextType = { flags: { [key: string]: boolean } + isInitialized: boolean onUpdateFlag: (key: string, value: boolean) => void } const FeaturePreviewContext = createContext<FeaturePreviewContextType>({ flags: EMPTY_OBJ, + isInitialized: false, onUpdateFlag: noop, }) @@ -34,10 +37,18 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = const [flags, setFlags] = useState(() => featurePreviews.reduce((a, b) => ({ ...a, [b.key]: false }), {}) ) + // Tracks whether `flags` reflects the loaded feature flags (vs. the pre-load + // defaults). Only set true once `initializeFlags` runs with `hasLoaded`. + const [isInitialized, setIsInitialized] = useState(false) const initializeFlags = useEffectEvent(() => { setFlags( featurePreviews.reduce((a, b) => { + // Platform-only previews can never be enabled outside the hosted platform + if (!IS_PLATFORM && b.isPlatformOnly) { + return { ...a, [b.key]: false } + } + const defaultOptIn = b.isDefaultOptIn const localStorageValue = safeLocalStorage.getItem(b.key) return { @@ -51,12 +62,16 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = useEffect(() => { if (typeof window !== 'undefined') { initializeFlags() + // Defer marking initialized until the underlying flags have loaded, so + // flag-derived defaults (e.g. default opt-in) are reflected in `flags`. + if (hasLoaded) setIsInitialized(true) } // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet) }, [hasLoaded]) const value = { flags, + isInitialized, onUpdateFlag: (key: string, value: boolean) => { safeLocalStorage.setItem(key, value ? 'true' : 'false') const updatedFlags = { ...flags, [key]: value } @@ -75,23 +90,24 @@ export const useIsColumnLevelPrivilegesEnabled = () => { } export const useUnifiedLogsPreview = () => { - const { flags, onUpdateFlag } = useFeaturePreviewContext() - const { hasLoaded: flagsHaveLoaded } = useContext(FeatureFlagContext) - const unifiedLogsEnabled = useFlag('unifiedLogs') + const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn') + const { flags, isInitialized, onUpdateFlag } = useFeaturePreviewContext() - const isLoading = !flagsHaveLoaded - const isEnabled = unifiedLogsEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] + const isLoading = !isInitialized + const isEnabled = flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] + + const hasToggledPreview = !!safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS) + const isDefaultOptIn = isInitialized && unifiedLogsDefaultOptIn && !hasToggledPreview const enable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, true) const disable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, false) - return { isEnabled, isEligible: unifiedLogsEnabled, isLoading, enable, disable } + return { isEnabled, isLoading, isDefaultOptIn, enable, disable } } export const useIsPgDeltaDiffEnabled = () => { const { flags } = useFeaturePreviewContext() - const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') - return pgDeltaDiffEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF] + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF] } export const useIsAdvisorRulesEnabled = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx index 4f8a00c3def..01ea7cffdc5 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx @@ -27,8 +27,8 @@ export const RLSTesterPreview = () => { <ul className="list-disc pl-6 text-sm text-foreground-light space-y-1"> <li> Show the "Test" button on the{' '} - <InlineLink href={`/project/${ref}/auth/policies`}> - Authentication Policies page + <InlineLink href={`/project/${ref}/database/policies`}> + Database Policies page </InlineLink> </li> </ul> diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index e70e3dd8f1b..1cdb5b0c3b0 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -20,12 +20,12 @@ export type FeaturePreview = { } export const useFeaturePreviews = (): FeaturePreview[] => { - const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs') - const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') const platformWebhooksEnabled = useFlag('platformWebhooks') const jitDbAccessEnabled = useFlag('jitDbAccess') const isMarketplaceEnabled = useFlag('marketplaceIntegrations') + const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn') + return useMemo( () => [ @@ -37,16 +37,16 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isNew: true, isPlatformOnly: false, isDefaultOptIn: false, - getRoute: (ref?: string) => `/project/${ref}/auth/policies`, + getRoute: (ref?: string) => `/project/${ref}/database/policies`, }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, name: 'Updated Logs interface', discussionsUrl: 'https://github.com/orgs/supabase/discussions/37234', - enabled: isUnifiedLogsPreviewAvailable, + enabled: true, isNew: true, isPlatformOnly: true, - isDefaultOptIn: false, + isDefaultOptIn: unifiedLogsDefaultOptIn, getRoute: (ref?: string) => `/project/${ref}/logs`, }, { @@ -67,7 +67,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isNew: false, isPlatformOnly: true, isDefaultOptIn: true, - enabled: pgDeltaDiffEnabled, + enabled: true, }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, @@ -110,12 +110,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => { getRoute: (ref?: string) => `/project/${ref}/integrations`, }, ].sort((a, b) => Number(b.isNew) - Number(a.isNew)), - [ - isUnifiedLogsPreviewAvailable, - pgDeltaDiffEnabled, - platformWebhooksEnabled, - jitDbAccessEnabled, - isMarketplaceEnabled, - ] + [unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled] ) } diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index bb416823fcd..8c652163256 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -7,7 +7,7 @@ import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useProjectDetailQuery } from '@/data/projects/project-detail-query' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganization' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from '@/lib/constants' @@ -68,7 +68,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { if (!isValidOrg) { toast.error('You do not have access to this organization') - router.push(`${DEFAULT_HOME}?error=org_not_found&org=${slug}`) + router.push(DEFAULT_HOME) return } } diff --git a/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx b/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx index e7033ebe929..7715bf6ca47 100644 --- a/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx @@ -5,7 +5,7 @@ import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -14,6 +14,7 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { AlertError } from '@/components/ui/AlertError' diff --git a/apps/studio/components/interfaces/Auth/Auth.constants.ts b/apps/studio/components/interfaces/Auth/Auth.constants.ts index 27458458bc6..d85bf61150f 100644 --- a/apps/studio/components/interfaces/Auth/Auth.constants.ts +++ b/apps/studio/components/interfaces/Auth/Auth.constants.ts @@ -42,5 +42,22 @@ export function urlRegex( ) } +export function normalizeRedirectUrl(url: string): string { + return url.trim().replace(/\s*,\s*$/, '') +} + +export function parseRedirectUrls(allowList?: string | null): string[] { + if (!allowList) return [] + + return Array.from( + new Set( + allowList + .split(',') + .map(normalizeRedirectUrl) + .filter((url) => url.length > 0) + ) + ) +} + // Use a const string to represent no chars option. Represented as empty string on the backend side. export const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index f8329d926bd..673a94855ad 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -18,7 +18,7 @@ import { SheetSection, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx index f8ee6ecb6f7..9d801843c98 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx @@ -14,7 +14,7 @@ import { import { getPhoneProviderValidationSchema, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation' import type { Provider } from './AuthProvidersForm.types' import { ProviderForm } from './ProviderForm' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ResourceList } from '@/components/ui/Resource/ResourceList' import { HorizontalShimmerWithIcon } from '@/components/ui/Shimmers' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx index 982d361637d..f470b23d02c 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx @@ -32,9 +32,9 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { NO_REQUIRED_CHARACTERS } from './Auth.constants' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -269,7 +269,7 @@ export const BasicAuthSettingsForm = () => { <code className="text-code-inline">authenticated</code> roles. We strongly advise{' '} <Link - href={`/project/${projectRef}/auth/policies`} + href={`/project/${projectRef}/database/policies`} className="text-foreground underline" > reviewing your RLS policies diff --git a/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx index fdf156cf7c3..a2a71318107 100644 --- a/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx +++ b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx @@ -38,7 +38,7 @@ import { } from './customProviders.utils' import { DeleteCustomProviderModal } from './DeleteCustomProviderModal' import { DisableCustomProviderModal } from './DisableCustomProviderModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { FilterPopover } from '@/components/ui/FilterPopover' import { Shortcut } from '@/components/ui/Shortcut' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx index cccd998524b..f5fa434afa3 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx @@ -256,7 +256,7 @@ const PASSWORD_CHANGED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.Data], + variables: [TemplateVariables.Email, TemplateVariables.Data, TemplateVariables.SiteURL], validationSchema: z.object({ MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -274,7 +274,12 @@ const EMAIL_CHANGED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.OldEmail, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.OldEmail, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -297,6 +302,7 @@ const PHONE_CHANGED_NOTIFICATION: AuthTemplate = { TemplateVariables.Phone, TemplateVariables.OldPhone, TemplateVariables.Data, + TemplateVariables.SiteURL, ], validationSchema: z.object({ MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'), @@ -315,7 +321,12 @@ const IDENTITY_LINKED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.Provider, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.Provider, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -333,7 +344,12 @@ const IDENTITY_UNLINKED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.Provider, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.Provider, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -351,7 +367,12 @@ const MFA_FACTOR_ENROLLED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.FactorType, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.FactorType, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -369,7 +390,12 @@ const MFA_FACTOR_UNENROLLED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.FactorType, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.FactorType, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.test.ts b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.test.ts index 0efbcd78e99..3df90628e9d 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.test.ts +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' -import { EMAIL_TEMPLATE_DOCS_ANCHORS } from './EmailTemplates.constants' +import { + AUTH_EMAIL_TEMPLATES_TERMINOLOGY_ANCHOR, + EMAIL_TEMPLATE_DOCS_ANCHORS, +} from './EmailTemplates.constants' import { AUTH_TEMPLATE_TYPES, type AuthTemplateType } from './EmailTemplates.types' /** Docs section headings from customizing-email-templates.mdx */ @@ -32,6 +35,12 @@ function docsHeadingToAnchor(heading: string) { .replace(/[ ]/g, '-') } +describe('EmailTemplates.constants: AUTH_EMAIL_TEMPLATES_TERMINOLOGY_ANCHOR', () => { + it('matches auth-email-templates.mdx heading slug', () => { + expect(AUTH_EMAIL_TEMPLATES_TERMINOLOGY_ANCHOR).toBe(docsHeadingToAnchor('Terminology')) + }) +}) + describe('EmailTemplates.constants: EMAIL_TEMPLATE_DOCS_ANCHORS', () => { it('covers every auth template type', () => { expect(Object.keys(EMAIL_TEMPLATE_DOCS_ANCHORS).sort()).toEqual([...AUTH_TEMPLATE_TYPES].sort()) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.ts b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.ts index ad52828a963..060b381adac 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.ts +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants.ts @@ -8,6 +8,11 @@ import { getAuthTemplateType } from './EmailTemplates.utils' export const AUTH_TEMPLATE_RESET_TYPES: AuthTemplateResetType[] = AUTH_TEMPLATE_TYPES.map(getAuthTemplateType) +/** Hosted email templates guide for dashboard users. */ +export const AUTH_EMAIL_TEMPLATES_DOCS_PATH = '/guides/auth/auth-email-templates' + +export const AUTH_EMAIL_TEMPLATES_TERMINOLOGY_ANCHOR = 'terminology' + /** Docs heading anchors for customizing-email-templates.mdx (ui Heading slugify output). */ export const EMAIL_TEMPLATE_DOCS_ANCHORS = { CONFIRMATION: 'authemailtemplateconfirmation', diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index e8f79f55cab..5f503ec80ad 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -26,7 +26,8 @@ import { isCustomEmailTemplateRestrictionStatusKnown, slugifyTitle, } from './EmailTemplates.utils' -import AlertError from '@/components/ui/AlertError' +import { SendEmailHookActiveAdmonition } from './SendEmailHookActiveAdmonition' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' @@ -79,6 +80,9 @@ export const EmailTemplates = () => { }) const usingBuiltInEmailSender = !hasCustomEmailSender(authConfig) + const hasSendEmailHook = !!( + authConfig?.HOOK_SEND_EMAIL_ENABLED && authConfig?.HOOK_SEND_EMAIL_URI + ) const isTemplateRestrictionStatusKnown = isCustomEmailTemplateRestrictionStatusKnown({ authConfig, organization: selectedOrganization, @@ -135,10 +139,24 @@ export const EmailTemplates = () => { )} {isSuccess && ( <> + {isTemplateEditBlocked && !hasSendEmailHook && ( + <PageSection> + <PageSectionContent> + <CustomEmailTemplateRestrictionAdmonition /> + </PageSectionContent> + </PageSection> + )} + + {hasSendEmailHook && ( + <PageSection> + <PageSectionContent> + <SendEmailHookActiveAdmonition /> + </PageSectionContent> + </PageSection> + )} + <PageSection> - {isTemplateEditBlocked ? ( - <CustomEmailTemplateRestrictionAdmonition /> - ) : usingBuiltInEmailSender ? ( + {usingBuiltInEmailSender && !isTemplateEditBlocked && ( <Admonition type="warning" title="Set up custom SMTP" @@ -160,7 +178,7 @@ export const EmailTemplates = () => { </Button> } /> - ) : null} + )} <PageSectionMeta> <PageSectionSummary> <PageSectionTitle>Authentication</PageSectionTitle> diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition.tsx new file mode 100644 index 00000000000..85aeb69b711 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition.tsx @@ -0,0 +1,38 @@ +import { useParams } from 'common' +import Link from 'next/link' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { InlineLink } from '@/components/ui/InlineLink' +import { DOCS_URL } from '@/lib/constants' + +/** + * Shown on template list and editor pages when a send-email hook is active. + * The hook bypasses template rendering entirely — Auth sends event metadata to + * the hook, not rendered HTML — so templates are irrelevant while it's enabled. + */ +export const SendEmailHookActiveAdmonition = () => { + const { ref: projectRef } = useParams() + + return ( + <Admonition + type="default" + layout="responsive" + title="Email templates are not used" + description={ + <> + A Send Email hook is active. Email data is sent to your hook instead of using these + templates.{' '} + <InlineLink href={`${DOCS_URL}/guides/auth/auth-hooks/send-email-hook`}> + Learn more + </InlineLink> + </> + } + actions={ + <Button asChild variant="default"> + <Link href={`/project/${projectRef}/auth/hooks?hook=send-email`}>Manage hook</Link> + </Button> + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx index 8b1f397f4f2..0538087f099 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from 'framer-motion' import { CardContent, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Markdown } from '@/components/interfaces/Markdown' import { ValidateSpamResponse } from '@/data/auth/validate-spam-mutation' diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx index dac6766a961..933268de677 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx @@ -124,7 +124,7 @@ const renderTemplateEditor = ({ }) useAsyncCheckPermissionsMock.mockReturnValue({ can: canUpdateConfig }) useAuthConfigUpdateMutationMock.mockReturnValue({ mutate: updateAuthConfigMock }) - useAuthTemplateResetMutationMock.mockReturnValue({ mutate: resetTemplateMock }) + useAuthTemplateResetMutationMock.mockReturnValue({ mutate: resetTemplateMock, isPending: false }) return render(<TemplateEditor template={confirmationTemplate} />) } diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index b24311c00da..dadfc4561de 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -19,10 +19,14 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' +import { + AUTH_EMAIL_TEMPLATES_DOCS_PATH, + AUTH_EMAIL_TEMPLATES_TERMINOLOGY_ANCHOR, +} from './EmailTemplates.constants' import type { AuthTemplate } from './EmailTemplates.types' import { hasCustomEmailSender } from './EmailTemplates.utils' import { ResetTemplateDialog } from './ResetTemplateDialog' @@ -366,15 +370,9 @@ export const TemplateEditor = ({ template, isReadOnly = false }: TemplateEditorP <p className="text-sm text-foreground-lighter"> Data placeholders that can be inserted into the subject or body.{' '} <InlineLink - href={`${DOCS_URL}/guides/auth/auth-email-templates#terminology`} + href={`${DOCS_URL}${AUTH_EMAIL_TEMPLATES_DOCS_PATH}#${AUTH_EMAIL_TEMPLATES_TERMINOLOGY_ANCHOR}`} > - Terminology - </InlineLink> - {' · '} - <InlineLink - href={`${DOCS_URL}/guides/local-development/customizing-email-templates#template-variables`} - > - Local development + Learn more </InlineLink> </p> </div> diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index c1dbe06022e..8b2561a7c65 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -27,6 +27,7 @@ import { SheetTitle, Switch, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' import * as z from 'zod' @@ -38,6 +39,7 @@ import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialo import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { DocsButton } from '@/components/ui/DocsButton' import FunctionSelector from '@/components/ui/FunctionSelector' +import { InlineLink } from '@/components/ui/InlineLink' import { SchemaSelector } from '@/components/ui/SchemaSelector' import { AuthConfigResponse } from '@/data/auth/auth-config-query' import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation' @@ -165,7 +167,7 @@ export const CreateHookSheet = ({ }) const isDirty = form.formState.isDirty - const values = form.watch() + const { postgresValues, hookType, selectedType, enabled } = form.watch() const { confirmOnClose, handleOpenChange, @@ -181,7 +183,7 @@ export const CreateHookSheet = ({ if ( hook.method.schema !== '' && hook.method.functionName !== '' && - hook.method.functionName !== values.postgresValues.functionName + hook.method.functionName !== postgresValues.functionName ) { permissionChanges = getRevokePermissionStatements( hook.method.schema, @@ -190,9 +192,9 @@ export const CreateHookSheet = ({ } } - if (values.postgresValues.functionName !== '') { - const schema = values.postgresValues.schema - const functionName = values.postgresValues.functionName + if (postgresValues.functionName !== '') { + const schema = postgresValues.schema + const functionName = postgresValues.functionName permissionChanges = [ ...permissionChanges, safeSql`-- Grant access to function to supabase_auth_admin @@ -204,11 +206,11 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti ] } return permissionChanges - }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) + }, [hook, postgresValues.schema, postgresValues.functionName]) const { mutate: updateAuthHooks, isPending: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({ onSuccess: () => { - toast.success(`Successfully created ${values.hookType}.`) + toast.success(`Successfully ${isCreating ? 'created' : 'updated'} ${hookType}.`) if (statements.length > 0) { executeSql({ projectRef, @@ -306,7 +308,7 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti <DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} /> </SheetHeader> <Separator /> - <SheetSection className="overflow-auto grow px-0"> + <SheetSection className="overflow-auto grow p-0"> <Form {...form}> <form id={FORM_ID} @@ -319,11 +321,11 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti control={form.control} render={({ field }) => ( <FormItemLayout - layout="flex" - className="px-5" - label={`Enable ${values.hookType}`} + layout="flex-row-reverse" + className="px-5 [&>div:first-child]:xl:w-1/5" + label={`Enable ${hookType}`} description={ - values.hookType === 'Send SMS hook' + hookType === 'Send SMS hook' ? 'SMS Provider settings will be disabled in favor of SMS hooks' : undefined } @@ -338,12 +340,32 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti </FormItemLayout> )} /> + + {hook.id === 'send-email' && enabled && ( + <div className="px-5"> + <Admonition type="default" title="This hook replaces your email templates"> + <p className="text-balance"> + While enabled, email data is sent to this hook instead. Your configured{' '} + <InlineLink href={`/project/${projectRef}/auth/templates`}> + email templates + </InlineLink>{' '} + will not be used.{' '} + <InlineLink href={`${DOCS_URL}/guides/auth/auth-hooks/send-email-hook`}> + Learn more + </InlineLink> + . + </p> + </Admonition> + </div> + )} + <Separator /> + <FormField control={form.control} name="selectedType" render={({ field }) => ( - <FormItemLayout label="Hook type" className="px-5"> + <FormItemLayout layout="horizontal" label="Hook type" className="px-5"> <FormControl> <RadioGroupStacked value={field.value} @@ -368,89 +390,94 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti </FormItemLayout> )} /> - {values.selectedType === 'postgres' ? ( + {selectedType === 'postgres' ? ( <> - <div className="grid grid-cols-2 gap-8 px-5"> - <FormField - key="postgresValues.schema" - control={form.control} - name="postgresValues.schema" - render={({ field }) => ( - <FormItemLayout - label="Postgres Schema" - description="Postgres schema where the function is defined" - > - <FormControl> - <SchemaSelector - size="small" - showError={false} - stopScrollPropagation - selectedSchemaName={field.value} - onSelectSchema={(name) => field.onChange(name)} - disabled={field.disabled} - /> - </FormControl> - </FormItemLayout> - )} - /> - <FormField - key="postgresValues.functionName" - control={form.control} - name="postgresValues.functionName" - render={({ field }) => ( - <FormItemLayout - label="Postgres function" - description="This function will be called by Supabase Auth each time the hook is triggered" - > - <FormControl> - <FunctionSelector - size="small" - schema={values.postgresValues.schema} - value={field.value} - stopScrollPropagation - onChange={field.onChange} - disabled={field.disabled} - filterFunction={(func) => { - if (supportedReturnTypes.includes(func.return_type)) { - const { value } = convertArgumentTypes({ - type: func.type, - value: func.argument_types, - }) - if (value.length !== 1) return false - return value[0].type === 'json' || value[0].type === 'jsonb' - } - return false - }} - noResultsLabel={ - <span> - No function with a single JSON/B argument - <br /> - and JSON/B - {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' - ? ' or void' - : ''}{' '} - return type found in this schema. - </span> + <FormField + key="postgresValues.schema" + control={form.control} + name="postgresValues.schema" + render={({ field }) => ( + <FormItemLayout + layout="horizontal" + className="px-5" + label="Postgres Schema" + description="Postgres schema where the function is defined" + > + <FormControl> + <SchemaSelector + size="small" + showError={false} + stopScrollPropagation + selectedSchemaName={field.value} + onSelectSchema={(name) => field.onChange(name)} + disabled={field.disabled} + /> + </FormControl> + </FormItemLayout> + )} + /> + <FormField + key="postgresValues.functionName" + control={form.control} + name="postgresValues.functionName" + render={({ field }) => ( + <FormItemLayout + layout="horizontal" + className="px-5" + label="Postgres function" + description="This function will be called by Supabase Auth each time the hook is triggered" + > + <FormControl> + <FunctionSelector + size="small" + schema={postgresValues.schema} + value={field.value} + stopScrollPropagation + onChange={field.onChange} + disabled={field.disabled} + filterFunction={(func) => { + if (supportedReturnTypes.includes(func.return_type)) { + const { value } = convertArgumentTypes({ + type: func.type, + value: func.argument_types, + }) + if (value.length !== 1) return false + return value[0].type === 'json' || value[0].type === 'jsonb' } - /> - </FormControl> - </FormItemLayout> - )} - /> - </div> + return false + }} + noResultsLabel={ + <span> + No function with a single JSON/B argument + <br /> + and JSON/B + {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' + ? ' or void' + : ''}{' '} + return type found in this schema. + </span> + } + /> + </FormControl> + </FormItemLayout> + )} + /> {statements.length > 0 && ( - <div className="h-72 w-full gap-3 flex flex-col"> - <p className="text-sm text-foreground-light px-5"> - The following statements will be executed on the selected function: - </p> - <CodeEditor - isReadOnly - id="postgres-hook-editor" - language="pgsql" - value={statements.join('\n\n')} - /> - </div> + <> + <Separator className="mb-4" /> + <div className="h-72 w-full gap-3 flex flex-col"> + <p className="text-sm text-foreground-light px-5"> + The following statements will be executed on the selected function: + </p> + <CodeEditor + isReadOnly + id="postgres-hook-editor" + language="pgsql" + value={statements.join('\n\n')} + /> + </div> + </> )} </> ) : ( @@ -461,11 +488,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti name="httpsValues.url" render={({ field }) => ( <FormItemLayout + layout="horizontal" label="URL" description="Supabase Auth will send a HTTPS POST request to this URL each time the hook is triggered." > <FormControl> - <Input {...field} /> + <Input {...field} placeholder="https://www.example.com" /> </FormControl> </FormItemLayout> )} @@ -476,11 +504,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti name="httpsValues.secret" render={({ field }) => ( <FormItemLayout + layout="horizontal" label="Secret" description={ <div className="flex items-center gap-x-2"> <p> - Should be a base64 encoded hook secret with a prefix{' '} + Should be a base64 encoded secret with a prefix{' '} <code className="text-code-inline">v1,whsec_</code>. </p> <InfoTooltip side="bottom" className="w-60 text-center"> @@ -496,8 +525,7 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti <Input {...field} className="rounded-r-none border-r-0" /> <Button variant="default" - size="small" - className="rounded-l-none text-xs" + className="rounded-l-none text-xs h-auto" onClick={() => { const authHookSecret = generateAuthHookSecret() form.setValue('httpsValues.secret', authHookSecret, { diff --git a/apps/studio/components/interfaces/Auth/Hooks/DeleteHookConfirmationDialog.tsx b/apps/studio/components/interfaces/Auth/Hooks/DeleteHookConfirmationDialog.tsx new file mode 100644 index 00000000000..b1c5eebd8be --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Hooks/DeleteHookConfirmationDialog.tsx @@ -0,0 +1,169 @@ +import { joinSqlFragments } from '@supabase/pg-meta' +import { useParams } from 'common' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + cn, +} from 'ui' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' + +import { isBeforeFreeTierTemplateBlockCutoff } from '../EmailTemplates/EmailTemplates.utils' +import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils' +import { type Hook } from './hooks.constants' +import { getRevokePermissionStatements } from './hooks.utils' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { useAuthConfigQuery } from '@/data/auth/auth-config-query' +import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation' +import { executeSql } from '@/data/sql/execute-sql-mutation' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +interface DeleteHookConfirmationDialogProps { + hook?: Hook + onOpenChange: (open: boolean) => void + onDeleteSuccess: () => void +} + +export const DeleteHookConfirmationDialog = ({ + hook, + onOpenChange, + onDeleteSuccess, +}: DeleteHookConfirmationDialogProps) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + const { data: selectedOrganization } = useSelectedOrganizationQuery() + const { data: authConfig } = useAuthConfigQuery({ projectRef }) + + const { mutate: updateAuthHooks, isPending: isDeletingAuthHook } = useAuthHooksUpdateMutation({ + onSuccess: async () => { + if (!hook) return + + const { method } = hook + if (method.type === 'postgres') { + try { + const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName) + await executeSql({ + projectRef, + connectionString: project!.connectionString, + sql: joinSqlFragments(revokeStatements, '\n'), + }) + toast.success(`Successfully deleted ${hook.title}`) + } catch (error) { + toast.warning( + `Deleted ${hook.title}, but failed to revoke permissions on ${method.schema}.${method.functionName}. You may want to revoke them manually.` + ) + } + } + onDeleteSuccess() + }, + onError: (error) => { + toast.error(`Failed to delete hook: ${error.message}`) + }, + }) + + const open = !!hook + + // Whether deleting the Send Email hook will lock template editing: + // post-cutoff Free plan projects without custom SMTP. + const willLockTemplates = + !!selectedOrganization && + selectedOrganization.plan?.id === 'free' && + !!project?.inserted_at && + !isBeforeFreeTierTemplateBlockCutoff(project.inserted_at) && + !isSmtpEnabled(authConfig) + + const handleDeleteSendEmailHook = async (): Promise<void> => { + if (!hook) return + updateAuthHooks({ + projectRef: projectRef!, + config: { + [hook.enabledKey]: false, + [hook.uriKey]: null, + [hook.secretsKey]: null, + }, + }) + } + + if (hook?.id === 'send-email') { + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Confirm to delete Send Email hook</AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-2"> + <p> + The {willLockTemplates ? 'default' : 'built-in'} email templates will be used when + sending authentication-related emails. + </p> + {willLockTemplates && ( + <p>Email templates cannot be edited on the Free plan without custom SMTP.</p> + )} + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + variant="danger" + loading={isDeletingAuthHook} + onClick={(e) => { + e.preventDefault() + handleDeleteSendEmailHook() + }} + > + Delete hook + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) + } + + return ( + <ConfirmationModal + visible={!!hook && hook.id !== 'send-email'} + size="small" + variant="destructive" + loading={isDeletingAuthHook} + title={`Confirm to delete ${hook?.title}`} + className={cn('md:px-0', hook?.method.type === 'postgres' && 'pb-0')} + confirmLabel="Delete" + confirmLabelLoading="Deleting" + onCancel={() => onOpenChange(false)} + onConfirm={handleDeleteSendEmailHook} + > + <div> + <p className="md:px-5 text-sm text-foreground-light"> + Are you sure you want to delete the {hook?.title}? + </p> + {hook?.method.type === 'postgres' && ( + <> + <p className="md:px-5 text-sm text-foreground-light"> + The following statements will be executed on the {hook?.method.schema}. + {hook?.method.functionName} function: + </p> + <div className="mt-4 h-72"> + <CodeEditor + isReadOnly + id="deletion-hook-editor" + language="pgsql" + value={getRevokePermissionStatements( + hook?.method.schema, + hook?.method.functionName + ).join('\n\n')} + /> + </div> + </> + )} + </div> + </ConfirmationModal> + ) +} diff --git a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx index 79c79626086..ba506eee826 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx @@ -1,20 +1,28 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Check, Webhook } from 'lucide-react' -import { Badge } from 'ui' +import { BookOpen, Check, Edit, MoreVertical, Trash, Webhook } from 'lucide-react' +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { Hook } from './hooks.constants' -import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { DocsButton } from '@/components/ui/DocsButton' +import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' interface HookCardProps { hook: Hook - onSelect: () => void + onSelectEdit: () => void + onSelectDelete: () => void } -export const HookCard = ({ hook, onSelect }: HookCardProps) => { +export const HookCard = ({ hook, onSelectEdit, onSelectDelete }: HookCardProps) => { const { can: canUpdateAuthHook } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') return ( @@ -27,14 +35,14 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => { <span className="text-sm text-foreground-lighter">{hook.subtitle}</span> <div className="text-sm flex flex-row space-x-5 py-4"> {hook.method.type === 'postgres' ? ( - <div className="flex flex-col w-full space-y-2"> + <div className="flex flex-col w-full space-y-2 max-w-xl"> <div className="flex flex-row items-center"> - <span className="text-foreground-light w-20">type</span> + <span className="text-foreground-light w-20">Type</span> <span className="text-foreground">Postgres function</span> </div> <div className="flex flex-row items-center"> <label htmlFor="schema" className="text-foreground-light w-20"> - schema + Schema </label> <Input id="schema" @@ -49,7 +57,7 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => { </div> <div className="flex flex-row items-center"> <label htmlFor="functionName" className="text-foreground-light w-20"> - function + Function </label> <Input id="functionName" @@ -64,14 +72,14 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => { </div> </div> ) : ( - <div className="flex flex-col w-full space-y-2"> + <div className="flex flex-col w-full space-y-2 max-w-xl"> <div className="flex flex-row items-center"> - <span className="text-foreground-light w-20">type</span> + <span className="text-foreground-light w-20">Type</span> <span className="text-foreground">HTTPS endpoint</span> </div> <div className="flex flex-row items-center"> <label htmlFor="url" className="text-foreground-light w-20"> - endpoint + Endpoint </label> <Input id="url" @@ -86,7 +94,7 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => { </div> <div className="flex flex-row items-center"> <label htmlFor="secret" className="text-foreground-light w-20"> - secret + Secret </label> <Input id="secret" @@ -103,38 +111,78 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => { </div> )} </div> - <div className="flex flex-row gap-2"> - <ButtonTooltip - variant="default" - disabled={!canUpdateAuthHook} - onClick={() => onSelect()} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateAuthHook - ? 'You need additional permissions to configure auth hooks' - : undefined, - }, - }} - > - Configure hook - </ButtonTooltip> - <DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} /> - </div> </div> + <div> - {hook.enabled ? ( - <Badge className="space-x-1" variant="success"> - <div className="h-3.5 w-3.5 bg-brand rounded-full flex justify-center items-center"> - <Check className="h-2 w-2 text-background-overlay " strokeWidth={6} /> - </div> - <span>Enabled</span> - </Badge> - ) : ( - <Badge variant="warning"> - <span>Disabled</span> - </Badge> - )} + <div className="flex items-center gap-x-2"> + {hook.enabled ? ( + <Badge className="space-x-1" variant="success"> + <div className="h-3.5 w-3.5 bg-brand rounded-full flex justify-center items-center"> + <Check className="h-2 w-2 text-background-overlay " strokeWidth={6} /> + </div> + <span>Enabled</span> + </Badge> + ) : ( + <Badge variant="warning"> + <span>Disabled</span> + </Badge> + )} + <DropdownMenu> + <DropdownMenuTrigger> + <Button + variant="default" + className="px-1" + icon={<MoreVertical />} + aria-label={`Open actions for ${hook.title}`} + /> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItemTooltip + className="gap-x-2" + disabled={!canUpdateAuthHook} + onClick={onSelectEdit} + tooltip={{ + content: { + text: !canUpdateAuthHook + ? 'You need additional permissions to configure auth hooks' + : undefined, + side: 'left', + }, + }} + > + <Edit size={12} /> + <span>Edit hook</span> + </DropdownMenuItemTooltip> + <DropdownMenuItem asChild className="gap-x-2"> + <a + target="_blank" + rel="noreferrer noopener" + href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} + > + <BookOpen size={12} /> + <span>Documentation</span> + </a> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItemTooltip + className="gap-x-2" + disabled={!canUpdateAuthHook} + onClick={onSelectDelete} + tooltip={{ + content: { + text: !canUpdateAuthHook + ? 'You need additional permissions to delete auth hooks' + : undefined, + side: 'left', + }, + }} + > + <Trash size={12} /> + <span>Delete hook</span> + </DropdownMenuItemTooltip> + </DropdownMenuContent> + </DropdownMenu> + </div> </div> </div> ) diff --git a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx index a40b77793d1..b07cc3dd605 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx @@ -1,11 +1,8 @@ -import { joinSqlFragments } from '@supabase/pg-meta/src/pg-format' import { useParams } from 'common' import { parseAsString, useQueryState } from 'nuqs' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { cn } from 'ui' -import { EmptyStatePresentational, GenericSkeletonLoader } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { PageSection, PageSectionAside, @@ -14,24 +11,22 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { AddHookDropdown } from './AddHookDropdown' import { CreateHookSheet } from './CreateHookSheet' +import { DeleteHookConfirmationDialog } from './DeleteHookConfirmationDialog' import { HookCard } from './HookCard' import { Hook, HOOKS_DEFINITIONS } from './hooks.constants' -import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' +import { extractMethod, isValidHook } from './hooks.utils' import { AlertError } from '@/components/ui/AlertError' -import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' -import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation' -import { executeSql } from '@/data/sql/execute-sql-mutation' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' export const HooksListing = () => { const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() + const { data: authConfig, error: authConfigError, @@ -41,32 +36,10 @@ export const HooksListing = () => { const [hook, setHook] = useQueryState('hook', parseAsString) - const [selectedHookForDeletion, setSelectedHookForDeletion] = useState<Hook | null>(null) + const [selectedHookForDeletion, setSelectedHookForDeletion] = useState<Hook>() const [addHookAsideOpen, setAddHookAsideOpen] = useState(false) const [addHookEmptyOpen, setAddHookEmptyOpen] = useState(false) - const { mutate: updateAuthHooks, isPending: isDeletingAuthHook } = useAuthHooksUpdateMutation({ - onSuccess: async () => { - if (!selectedHookForDeletion) return - - const { method } = selectedHookForDeletion - if (method.type === 'postgres') { - const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName) - await executeSql({ - projectRef, - connectionString: project!.connectionString, - sql: joinSqlFragments(revokeStatements, '\n'), - }) - } - toast.success(`${selectedHookForDeletion.title} has been deleted.`) - setSelectedHookForDeletion(null) - setHook(null) - }, - onError: (error) => { - toast.error(`Failed to delete hook: ${error.message}`) - }, - }) - const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { ...definition, @@ -158,7 +131,14 @@ export const HooksListing = () => { <div className="-space-y-px"> {validHooks.map((hook) => { - return <HookCard key={hook.enabledKey} hook={hook} onSelect={() => setHook(hook.id)} /> + return ( + <HookCard + key={hook.enabledKey} + hook={hook} + onSelectEdit={() => setHook(hook.id)} + onSelectDelete={() => setSelectedHookForDeletion(hook)} + /> + ) })} </div> @@ -173,54 +153,16 @@ export const HooksListing = () => { authConfig={authConfig!} /> - <ConfirmationModal - visible={!!selectedHookForDeletion} - size="large" - variant="destructive" - loading={isDeletingAuthHook} - title={`Confirm to delete ${selectedHookForDeletion?.title}`} - className={cn('md:px-0', selectedHookForDeletion?.method.type === 'postgres' && 'pb-0')} - confirmLabel="Delete" - confirmLabelLoading="Deleting" - onCancel={() => setSelectedHookForDeletion(null)} - onConfirm={() => { - if (!selectedHookForDeletion) return - updateAuthHooks({ - projectRef: projectRef!, - config: { - [selectedHookForDeletion.enabledKey]: false, - [selectedHookForDeletion.uriKey]: null, - [selectedHookForDeletion.secretsKey]: null, - }, - }) + <DeleteHookConfirmationDialog + hook={selectedHookForDeletion} + onOpenChange={(open) => { + if (!open) setSelectedHookForDeletion(undefined) }} - > - <div> - <p className="md:px-5 text-sm text-foreground-light"> - Are you sure you want to delete the {selectedHookForDeletion?.title}? - </p> - {selectedHookForDeletion?.method.type === 'postgres' && ( - <> - <p className="md:px-5 text-sm text-foreground-light"> - The following statements will be executed on the{' '} - {selectedHookForDeletion?.method.schema}. - {selectedHookForDeletion?.method.functionName} function: - </p> - <div className="mt-4 h-72"> - <CodeEditor - isReadOnly - id="deletion-hook-editor" - language="pgsql" - value={getRevokePermissionStatements( - selectedHookForDeletion?.method.schema, - selectedHookForDeletion?.method.functionName - ).join('\n\n')} - /> - </div> - </> - )} - </div> - </ConfirmationModal> + onDeleteSuccess={() => { + setSelectedHookForDeletion(undefined) + setHook(null) + }} + /> </PageSectionContent> </PageSection> ) diff --git a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx index 372554d3a6d..1bcc5c588aa 100644 --- a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx @@ -27,7 +27,6 @@ import { Switch, WarningIcon, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -37,11 +36,12 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx index 1a2a128334c..5a85a8a0183 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx @@ -37,7 +37,7 @@ import { OAUTH_APP_CLIENT_TYPE_OPTIONS, OAUTH_APP_REGISTRATION_TYPE_OPTIONS, } from './oauthApps.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { FilterPopover } from '@/components/ui/FilterPopover' import { Shortcut } from '@/components/ui/Shortcut' diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx index c148a09e5e2..8b4c35a69f5 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx @@ -1,5 +1,7 @@ import { useParams } from 'common' import { Card, CardContent, cn } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, PageSectionContent, @@ -7,9 +9,7 @@ import { PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import { Input } from 'ui-patterns/DataInputs/Input' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +} from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { useOpenIDConfigurationQuery } from '@/data/oauth-server-apps/oauth-openid-configuration-query' diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx index 37d08c9199c..9c77405434d 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx @@ -25,7 +25,7 @@ import * as z from 'zod' import { OAuthEndpointsTable } from './OAuthEndpointsTable' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query' diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx index 2873f7bf9c7..9e21fa65f1d 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx @@ -5,7 +5,6 @@ import { BarChart2, ChevronRight, ExternalLink, Telescope } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { AiIconAnimation, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { StatusCode } from 'ui-patterns' import { Chart, ChartActions, @@ -24,6 +23,7 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { StatusCode } from 'ui-patterns/StatusCode' import { AuthErrorCodeRow, @@ -40,7 +40,7 @@ import { getMetricValues, } from './OverviewUsage.constants' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ErrorCodeTooltip } from '@/components/ui/ErrorCodeTooltip/ErrorCodeTooltip' import { Service } from '@/data/graphql/graphql' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' diff --git a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx index 2a3cb1234a5..dc6976ef429 100644 --- a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx @@ -21,7 +21,7 @@ import * as z from 'zod' import { validateRpId, validateWebAuthnOrigins } from './PasskeysSettingsForm.utils' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import type { components } from '@/data/api' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx b/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx index 8041604cc19..1b08af1dce1 100644 --- a/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx @@ -21,13 +21,13 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { ScaffoldSection, ScaffoldSectionTitle } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts deleted file mode 100644 index e785b11c2b9..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { safeSql } from '@supabase/pg-meta' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - generateAiPoliciesForTable, - generateProgrammaticPoliciesForTable, - generateStartingPoliciesForTable, - type GeneratedPolicy, -} from './Policies.utils' -import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' - -// Mock generateSqlPolicy for AI tests -const mockGenerateSqlPolicy = vi.fn() -vi.mock('@/data/ai/sql-policy-mutation', () => ({ - generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args), -})) - -// Helper to create a foreign key constraint -const createForeignKey = (overrides: Partial<ForeignKeyConstraint> = {}): ForeignKeyConstraint => ({ - id: 1, - constraint_name: 'fk_constraint', - source_id: 100, - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_id: 200, - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - deletion_action: 'NO ACTION', - update_action: 'NO ACTION', - ...overrides, -}) - -describe('Policies.utils - Policy Generation', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('generateProgrammaticPoliciesForTable', () => { - it('should generate 4 CRUD policies for direct FK to auth.users', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - - const commands = policies.map((p) => p.command) - expect(commands).toContain('SELECT') - expect(commands).toContain('INSERT') - expect(commands).toContain('UPDATE') - expect(commands).toContain('DELETE') - }) - - it('should return empty array when no FK path to auth.users exists', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['category_id'], - target_schema: 'public', - target_table: 'categories', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(0) - }) - - it('should return empty array when foreignKeyConstraints is empty', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - }) - - expect(policies).toHaveLength(0) - }) - - it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => { - // posts -> profiles -> auth.users - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - id: 1, - source_schema: 'public', - source_table: 'posts', - source_columns: ['profile_id'], - target_schema: 'public', - target_table: 'profiles', - target_columns: ['id'], - }), - createForeignKey({ - id: 2, - source_schema: 'public', - source_table: 'profiles', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - - // Check that the expression contains EXISTS for indirect path - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.definition).toContain('exists') - expect(selectPolicy?.sql).toContain('exists') - }) - - describe('policy structure validation', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - it('should include all required fields in generated policies', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - for (const policy of policies) { - expect(policy).toHaveProperty('name') - expect(policy).toHaveProperty('sql') - expect(policy).toHaveProperty('command') - expect(policy).toHaveProperty('table', 'posts') - expect(policy).toHaveProperty('schema', 'public') - expect(policy).toHaveProperty('action', 'PERMISSIVE') - expect(policy).toHaveProperty('roles') - expect(policy.roles).toContain('authenticated') - } - }) - - it('SELECT policy should have definition but no check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.definition).toBeDefined() - expect(selectPolicy?.check).toBeUndefined() - }) - - it('DELETE policy should have definition but no check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const deletePolicy = policies.find((p) => p.command === 'DELETE') - expect(deletePolicy?.definition).toBeDefined() - expect(deletePolicy?.check).toBeUndefined() - }) - - it('INSERT policy should have check but no definition', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const insertPolicy = policies.find((p) => p.command === 'INSERT') - expect(insertPolicy?.definition).toBeUndefined() - expect(insertPolicy?.check).toBeDefined() - }) - - it('UPDATE policy should have both definition and check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const updatePolicy = policies.find((p) => p.command === 'UPDATE') - expect(updatePolicy?.definition).toBeDefined() - expect(updatePolicy?.check).toBeDefined() - }) - - it('should generate correct SQL syntax for direct FK', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.sql).toContain('CREATE POLICY') - expect(selectPolicy?.sql).toContain('public.posts') - expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT') - expect(selectPolicy?.sql).toContain('TO authenticated') - expect(selectPolicy?.sql).toContain('USING') - expect(selectPolicy?.sql).toContain('auth.uid()') - }) - }) - - it('should handle non-public schema', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'private', - source_table: 'documents', - source_columns: ['owner_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'documents', schema: 'private' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - expect(policies[0].schema).toBe('private') - expect(policies[0].sql).toContain('private.documents') - }) - }) - - describe('generateAiPoliciesForTable', () => { - const mockAiPolicies: GeneratedPolicy[] = [ - { - name: 'ai_select_policy', - sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);', - command: 'SELECT', - table: 'posts', - schema: 'public', - definition: safeSql`true`, - action: 'PERMISSIVE', - roles: ['public'], - }, - ] - - it('should return policies from AI when called with valid inputs', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }, { name: 'title' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({ - tableName: 'posts', - schema: 'public', - columns: ['id', 'title'], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - expect(policies).toEqual(mockAiPolicies) - }) - - it('should return empty array when connectionString is null', async () => { - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: null, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should return empty array when connectionString is undefined', async () => { - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: undefined, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should handle API errors gracefully and return empty array', async () => { - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - mockGenerateSqlPolicy.mockRejectedValue(new Error('API error')) - - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(policies).toEqual([]) - expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error)) - - consoleLogSpy.mockRestore() - }) - - it('should trim column names before sending to API', async () => { - mockGenerateSqlPolicy.mockResolvedValue([]) - - await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: ' id ' }, { name: ' title ' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalledWith( - expect.objectContaining({ - columns: ['id', 'title'], - }) - ) - }) - }) - - describe('generateStartingPoliciesForTable', () => { - const mockAiPolicies: GeneratedPolicy[] = [ - { - name: 'ai_policy', - sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);', - command: 'SELECT', - table: 'posts', - schema: 'public', - definition: safeSql`true`, - action: 'PERMISSIVE', - roles: ['public'], - }, - ] - - it('should use programmatic policies when FK path exists (does not call AI)', async () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(policies).toHaveLength(4) - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - }) - - it('should fall back to AI when no FK path exists and enableAi is true', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalled() - expect(policies).toEqual(mockAiPolicies) - }) - - it('should return empty array when no FK path exists and enableAi is false', async () => { - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: false, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should return empty array when no FK path and AI returns empty', async () => { - mockGenerateSqlPolicy.mockResolvedValue([]) - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(policies).toEqual([]) - }) - - it('should prioritize programmatic over AI even when both could generate policies', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - // Should return 4 programmatic policies, not 1 AI policy - expect(policies).toHaveLength(4) - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - }) - }) -}) diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts deleted file mode 100644 index d98c9322558..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { - acceptUntrustedSql, - ident, - safeSql, - untrustedSql, - type DisplayableSqlFragment, - type SafeSqlFragment, -} from '@supabase/pg-meta' -import type { PGPolicy } from '@supabase/pg-meta' -import { has, isEmpty, isEqual } from 'lodash' - -import { - DraftPostgresPolicyCreatePayload, - DraftPostgresPolicyUpdatePayload, - PolicyFormField, - PolicyForReview, -} from './Policies.types' -import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation' -import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation' -import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' - -/** - * Returns an array of SQL statements that will preview in the review step of the policy editor - * @param {*} policyFormFields { name, using, check, command } - */ - -export const createSQLPolicy = ( - policyFormFields: PolicyFormField, - originalPolicyFormFields?: PGPolicy -) => { - const { definition, check } = policyFormFields - const formattedPolicyFormFields = { - ...policyFormFields, - definition: definition - ? definition.replace(/\s+/g, ' ').trim() - : definition === undefined - ? null - : definition, - check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check, - } - - if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) { - return createSQLStatementForCreatePolicy(formattedPolicyFormFields) - } - - // If there are no changes, return an empty object - if (isEqual(policyFormFields, originalPolicyFormFields)) { - return {} - } - - // Extract out all the fields that updated - const fieldsToUpdate: any = {} - if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) { - fieldsToUpdate.name = formattedPolicyFormFields.name - } - if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) { - fieldsToUpdate.definition = formattedPolicyFormFields.definition - } - if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) { - fieldsToUpdate.check = formattedPolicyFormFields.check - } - if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) { - fieldsToUpdate.roles = formattedPolicyFormFields.roles - } - - if (!isEmpty(fieldsToUpdate)) { - return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate) - } - - return {} -} - -const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => { - const { name, definition, check, command, schema, table } = policyFormFields - const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles - const description = `Add policy for the ${command} operation under the policy "${name}"` - const statement = [ - `CREATE POLICY "${name}" ON "${schema}"."${table}"`, - `AS PERMISSIVE FOR ${command}`, - `TO ${roles.join(', ')}`, - `${definition ? `USING (${definition})` : ''}`, - `${check ? `WITH CHECK (${check})` : ''}`, - ].join('\n') - - return { description, statement } -} - -const createSQLStatementForUpdatePolicy = ( - policyFormFields: PolicyFormField, - fieldsToUpdate: Partial<PolicyFormField> -): PolicyForReview => { - const { name, schema, table } = policyFormFields - - const definitionChanged = has(fieldsToUpdate, ['definition']) - const checkChanged = has(fieldsToUpdate, ['check']) - const nameChanged = has(fieldsToUpdate, ['name']) - const rolesChanged = has(fieldsToUpdate, ['roles']) - - const parameters = Object.keys(fieldsToUpdate) - const description = `Update policy's ${ - parameters.length === 1 - ? parameters[0] - : `${parameters.slice(0, parameters.length - 1).join(', ')} and ${ - parameters[parameters.length - 1] - }` - } ` - const roles = - (fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[]) - - const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"` - const statement = [ - 'BEGIN;', - ...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []), - ...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []), - ...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []), - ...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []), - 'COMMIT;', - ].join('\n') - - return { description, statement } -} - -// These constructors return DRAFT payloads — `definition`/`check` are still -// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture -// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that -// it was reached via a deliberate user action. -export const createPayloadForCreatePolicy = ( - policyFormFields: PolicyFormField -): DraftPostgresPolicyCreatePayload => { - const { name, schema, table, command, definition, check, roles } = policyFormFields - return { - name, - schema, - table, - action: 'PERMISSIVE', - command: command || undefined, - definition: !definition ? undefined : untrustedSql(definition), - check: !check ? undefined : untrustedSql(check), - roles: roles.length > 0 ? roles : undefined, - } -} - -export const createPayloadForUpdatePolicy = ( - policyFormFields: PolicyFormField, - originalPolicyFormFields: PGPolicy -): DraftPostgresPolicyUpdatePayload => { - const { definition, check } = policyFormFields - const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition - const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check - - const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id } - - if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) { - payload.name = policyFormFields.name - } - if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) { - payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition) - } - if (!isEqual(formattedCheck, originalPolicyFormFields.check)) { - payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck) - } - if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) { - if (policyFormFields.roles.length === 0) payload.roles = ['public'] - else payload.roles = policyFormFields.roles || undefined - } - - return payload -} - -// --- Policy Generation --- - -/** - * A policy generated for display/staging in the table editor. - * `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance: - * programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation - * produces `UntrustedSqlFragment` (third-party output). Consumers must promote via - * `acceptUntrustedSql` at a user gesture before executing. - */ -export type GeneratedPolicy = Required< - Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'> -> & - Pick<CreatePolicyBody, 'command'> & { - definition?: DisplayableSqlFragment - check?: DisplayableSqlFragment - sql: string - } - -/** - * A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to - * `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture - * required to execute this SQL has already happened. - */ -export type AcceptedGeneratedPolicy = Omit<GeneratedPolicy, 'definition' | 'check'> & { - definition?: SafeSqlFragment - check?: SafeSqlFragment -} - -/** - * Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}. - * ONLY call from an event handler tied to a deliberate user action (e.g. the Save click - * on the table editor). Never call from useEffect, render, or any path that runs without - * a user gesture. - */ -export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({ - ...policy, - definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition), - check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check), -}) - -type Relationship = { - source_schema: string - source_table_name: string - source_column_name: string - target_table_schema: string - target_table_name: string - target_column_name: string -} - -/** - * Gets relationships for a specific table from FK constraints. - * Returns relationships where the table is the source. - */ -const getRelationshipsForTable = ({ - schema, - table, - fkConstraints, -}: { - schema: string - table: string - fkConstraints: ForeignKeyConstraint[] -}): Relationship[] => { - return fkConstraints - .filter((fk) => fk.source_schema === schema && fk.source_table === table) - .flatMap((fk) => - fk.source_columns.map((sourceCol, i) => ({ - source_schema: fk.source_schema, - source_table_name: fk.source_table, - source_column_name: sourceCol, - target_table_schema: fk.target_schema, - target_table_name: fk.target_table, - target_column_name: fk.target_columns[i], - })) - ) -} - -/** - * BFS to find shortest path from table to auth.users via foreign key relationships. - * Returns null if no path exists within maxDepth. - */ -const findPathToAuthUsers = ( - startTable: { schema: string; name: string }, - allForeignKeyConstraints: ForeignKeyConstraint[], - maxDepth = 3 -): Relationship[] | null => { - const startRelationships = getRelationshipsForTable({ - schema: startTable.schema, - table: startTable.name, - fkConstraints: allForeignKeyConstraints, - }) - - const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [ - { table: startTable, path: [] }, - ] - const visited = new Set<string>() - visited.add(`${startTable.schema}.${startTable.name}`) - - while (queue.length > 0) { - const queueItem = queue.shift() - if (!queueItem) continue - - const { table, path } = queueItem - if (path.length >= maxDepth) continue - - const relationships = - path.length === 0 - ? startRelationships - : getRelationshipsForTable({ - schema: table.schema, - table: table.name, - fkConstraints: allForeignKeyConstraints, - }) - - for (const rel of relationships) { - // Found path to auth.users - if ( - rel.target_table_schema === 'auth' && - rel.target_table_name === 'users' && - rel.target_column_name === 'id' - ) { - return [...path, rel] - } - - const targetId = `${rel.target_table_schema}.${rel.target_table_name}` - if (visited.has(targetId)) continue - - // Add target table to queue for further exploration - queue.push({ - table: { schema: rel.target_table_schema, name: rel.target_table_name }, - path: [...path, rel], - }) - visited.add(targetId) - } - } - - return null -} - -/** Generates SQL expression for RLS policy based on FK path to auth.users */ -const buildPolicyExpression = (path: Relationship[]): SafeSqlFragment => { - if (path.length === 0) return safeSql`` - - // Direct FK to auth.users - if (path.length === 1) { - return safeSql`(select auth.uid()) = ${ident(path[0].source_column_name)}` - } - - // Indirect path - build EXISTS with JOINs - const [first, ...rest] = path - const firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}` - const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}` - const last = path[path.length - 1] - - const joins = rest.slice(0, -1).reduce<SafeSqlFragment>( - (acc, r) => { - const targetSchema = ident(r.target_table_schema) - const targetTable = ident(r.target_table_name) - const targetColumn = ident(r.target_column_name) - - const sourceSchema = ident(r.source_schema) - const sourceTable = ident(r.source_table_name) - const sourceColumn = ident(r.source_column_name) - const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}` - return acc.length === 0 ? join : safeSql`${acc}\n ${join}` - }, - safeSql`` - ) - - return safeSql`exists ( - select 1 from ${firstTarget} - ${joins} - where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)} - and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid()) -)` -} - -/** Builds policy SQL for all CRUD operations */ -const buildPoliciesForPath = ( - table: { name: string; schema: string }, - path: Relationship[] -): GeneratedPolicy[] => { - const expression = buildPolicyExpression(path) - const targetCol = path[0].source_column_name - - return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => { - const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}` - const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO authenticated` - - const sql = - command === 'INSERT' - ? `${base} WITH CHECK (${expression});` - : command === 'UPDATE' - ? `${base} USING (${expression}) WITH CHECK (${expression});` - : `${base} USING (${expression});` - - // Structured data for mutation API - const definition = command === 'INSERT' ? undefined : expression - const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression - - return { - name, - sql, - command, - table: table.name, - schema: table.schema, - definition, - check, - action: 'PERMISSIVE' as const, - roles: ['authenticated'], - } - }) -} - -/** - * Generates RLS policies programmatically based on FK relationships to auth.users. - */ -export const generateProgrammaticPoliciesForTable = ({ - table, - foreignKeyConstraints, -}: { - table: { name: string; schema: string } - foreignKeyConstraints: ForeignKeyConstraint[] -}): GeneratedPolicy[] => { - try { - const path = findPathToAuthUsers(table, foreignKeyConstraints) - - if (path?.length) { - return buildPoliciesForPath(table, path) - } - } catch (error) { - // Silently fail - caller will handle empty result - } - - return [] -} - -/** - * Generates RLS policies using AI. - */ -export const generateAiPoliciesForTable = async ({ - table, - columns, - projectRef, - connectionString, -}: { - table: { name: string; schema: string } - columns: { name: string }[] - projectRef: string - connectionString?: string | null -}): Promise<GeneratedPolicy[]> => { - if (!connectionString) return [] - - try { - return await generateSqlPolicy({ - tableName: table.name, - schema: table.schema, - columns: columns.map((col) => col.name.trim()), - projectRef, - connectionString: connectionString ?? '', - }) - } catch (error) { - console.log('AI policy generation failed:', error) - return [] - } -} - -/** - * Generates RLS policies for a table. - * First tries programmatic generation based on FK relationships to auth.users. - * Falls back to AI generation if no path exists. - */ -export const generateStartingPoliciesForTable = async ({ - table, - foreignKeyConstraints, - columns, - projectRef, - connectionString, - enableAi, -}: { - table: { name: string; schema: string } - foreignKeyConstraints: ForeignKeyConstraint[] - columns: { name: string }[] - projectRef: string - connectionString?: string | null - enableAi: boolean -}): Promise<GeneratedPolicy[]> => { - // Try programmatic generation first - const programmaticPolicies = generateProgrammaticPoliciesForTable({ - table, - foreignKeyConstraints, - }) - - if (programmaticPolicies.length > 0) { - return programmaticPolicies - } - - // Fall back to AI generation - if (enableAi) { - return await generateAiPoliciesForTable({ - table, - columns, - projectRef, - connectionString, - }) - } - - return [] -} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx deleted file mode 100644 index 776238de5a8..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import Editor, { Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react' -import { noop } from 'lodash' -import type { editor } from 'monaco-editor' -import { MutableRefObject, useEffect, useRef } from 'react' -import { cn } from 'ui' - -import { Markdown } from '@/components/interfaces/Markdown' -import { formatSql } from '@/lib/formatSql' - -// [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard? -// Feels like we're creating multiple copies of Editor. I'm keen to make this one the defacto as well so lets make sure -// this component does not have RLS specific logic - -interface RLSCodeEditorProps { - id: string - defaultValue?: string - onInputChange?: (value?: string) => void - wrapperClassName?: string - className?: string - value?: string - placeholder?: string - readOnly?: boolean - - disableTabToUsePlaceholder?: boolean - lineNumberStart?: number - onChange?: () => void - onMount?: () => void - - editorRef: MutableRefObject<editor.IStandaloneCodeEditor | null> - monacoRef?: MutableRefObject<Monaco> -} - -export const RLSCodeEditor = ({ - id, - defaultValue, - onInputChange, - wrapperClassName, - className, - value, - placeholder, - readOnly = false, - - disableTabToUsePlaceholder = false, - lineNumberStart, - onChange = noop, - onMount: _onMount = noop, - - editorRef, - monacoRef, -}: RLSCodeEditorProps) => { - const hasValue = useRef<editor.IContextKey<boolean>>(null) - const monaco = useMonaco() - - const placeholderId = `monaco-placeholder-${id}` - const options: editor.IStandaloneEditorConstructionOptions = { - tabSize: 2, - fontSize: 13, - readOnly, - minimap: { enabled: false }, - wordWrap: 'on' as const, - contextmenu: true, - lineNumbers: - lineNumberStart !== undefined ? (num) => (num + lineNumberStart).toString() : undefined, - glyphMargin: undefined, - lineNumbersMinChars: 4, - folding: undefined, - scrollBeyondLastLine: false, - } - - const onMount: OnMount = async (editor, monaco) => { - editorRef.current = editor - if (monacoRef !== undefined) monacoRef.current = monaco - - hasValue.current = editor.createContextKey('hasValue', false) - const placeholderEl = document.getElementById(placeholderId) as HTMLElement | null - if (placeholderEl && placeholder !== undefined && (value ?? '').trim().length === 0) { - placeholderEl.style.display = 'block' - } - - if (!disableTabToUsePlaceholder) { - editor.addCommand( - monaco.KeyCode.Tab, - () => { - editor.executeEdits('source', [ - { - // @ts-ignore - identifier: 'add-placeholder', - range: new monaco.Range(1, 1, 1, 1), - text: (placeholder ?? '') - .split('\n\n') - .join('\n') - .replaceAll('*', '') - .replaceAll(' ', ''), - }, - ]) - }, - '!hasValue' - ) - } - - editor.focus() - - _onMount() - } - - const onChangeContent: OnChange = (value) => { - if (hasValue.current) { - hasValue.current.set((value ?? '').length > 0) - } - - const placeholderEl = document.getElementById(placeholderId) as HTMLElement | null - if (placeholderEl) { - if (!value) { - placeholderEl.style.display = 'block' - } else { - placeholderEl.style.display = 'none' - } - } - - onChange() - onInputChange?.(value) - } - - // when the value has changed, trigger the onChange callback so that the height of the container can be adjusted. - // Happens when the value wordwraps and is updated via a template. - useEffect(() => { - onChange() - }, [value]) - - useEffect(() => { - if (monaco) { - // Enable pgsql format - const formatprovider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { - async provideDocumentFormattingEdits(model: any) { - const value = model.getValue() - const formatted = formatSql(value) - return [ - { - range: model.getFullModelRange(), - text: formatted.trim(), - }, - ] - }, - }) - - return () => { - formatprovider.dispose() - } - } - }, [monaco]) - - useEffect(() => { - if (value !== undefined && value.trim().length > 0) { - const placeholderEl = document.getElementById(placeholderId) as HTMLElement | null - if (placeholderEl) placeholderEl.style.display = 'none' - } - }, [value]) - - return ( - <> - <Editor - path={id} - theme="supabase" - wrapperProps={{ className: cn(wrapperClassName) }} - className={cn(className, 'monaco-editor')} - value={value ?? undefined} - defaultLanguage="pgsql" - defaultValue={defaultValue ?? undefined} - options={options} - onMount={onMount} - onChange={onChangeContent} - /> - {placeholder !== undefined && ( - <div - id={placeholderId} - className={cn( - 'monaco-placeholder absolute top-0 left-[57px] text-sm pointer-events-none font-mono tracking-tighter', - '[&>div>p]:text-foreground-lighter [&>div>p]:m-0!' - )} - style={{ display: 'none' }} - > - <Markdown content={placeholder} /> - </div> - )} - </> - ) -} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/PolicyTemplates.constants.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/PolicyTemplates.constants.ts deleted file mode 100644 index 19fe1d66272..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/PolicyTemplates.constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' - -export interface PolicyTemplate { - id: string - preview: boolean - templateName: string - description: string - name: string - statement: string - definition: SafeSqlFragment - check: SafeSqlFragment - command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL' - roles: Array<string> -} diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx index 1b4876e86e8..a626f8a0de8 100644 --- a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx @@ -21,7 +21,6 @@ import { SelectValue, Switch, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -31,12 +30,13 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx deleted file mode 100644 index d56074cc27c..00000000000 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { - acceptUntrustedSql, - safeSql, - type SafeSqlFragment, - type UntrustedSqlFragment, -} from '@supabase/pg-meta' -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@ui/components/shadcn/ui/select' -import { LOCAL_STORAGE_KEYS, useFlag } from 'common' -import { Code, ExternalLink } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' -import { - Button, - DialogSectionSeparator, - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetSection, - SheetTitle, - SheetTrigger, -} from 'ui' -import { Admonition } from 'ui-patterns' - -import { InferredSQLViewer } from './InferredSQLViewer' -import { type ParseQueryResults } from './RLSTester.types' -import { RLSTesterEmptyState } from './RLSTesterEmptyState' -import { RLSTesterResults } from './RLSTesterResults' -import { RoleSelector } from './RoleSelector' -import { SandboxManagement } from './SandboxManagement' -import { UserSelector } from './UserSelector' -import { UserSqlEditor } from './UserSqlEditor' -import { useTestQueryRLS } from './useTestQueryRLS' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' -import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' -import { useTrack } from '@/lib/telemetry/track' -import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' -import { PostgresSandboxProvider } from '@/state/postgres-sandbox/sandbox' -import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' -import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' - -interface RLSTesterSheetProps { - handleSelectEditPolicy: (policy: Policy) => void -} - -export const RLSTesterSheet = (props: RLSTesterSheetProps) => { - return ( - <PostgresSandboxProvider> - <RLSTesterSheetContents {...props} /> - </PostgresSandboxProvider> - ) -} - -const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { - const track = useTrack() - const aiSnap = useAiAssistantStateSnapshot() - const { openSidebar } = useSidebarManagerSnapshot() - const { setRole } = useRoleImpersonationStateSnapshot() - const sandboxEnabled = useFlag('rlsTesterSandbox') - - const [open, setOpen] = useState(false) - const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon') - - const [format, setFormat] = useState<'sql' | 'lib'>('sql') - const [inferredSQL, setInferredSQL] = useState<UntrustedSqlFragment>() - - const [value, setValue] = useState<SafeSqlFragment>(safeSql``) - const [results, setResults] = useState<Object[] | null>(null) - const [autoLimit, setAutoLimit] = useState(false) - const [parseQueryResults, setParseQueryResults] = useState<ParseQueryResults>() - - const { - testQuery, - inferSQLFromLib, - isLoading, - isInferring, - executeSqlError, - parseQueryError, - parseClientCodeError, - } = useTestQueryRLS() - - const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) - - const handleValueChange = (sql: SafeSqlFragment) => { - setValue(sql) - if (format !== 'lib') return - - if (debounceRef.current !== null) clearTimeout(debounceRef.current) - if (!sql) return - - debounceRef.current = setTimeout(() => { - inferSQLFromLib(sql, setInferredSQL) - }, 1500) - } - - const executionCallbacks = { - option: selectedOption, - onExecuteSQL: ({ result, isAutoLimit }: { result: Object[] | null; isAutoLimit: boolean }) => { - setResults(result) - setAutoLimit(isAutoLimit) - }, - onParseQuery: setParseQueryResults, - } - - const onRunQuery = async () => { - if (format === 'lib') { - if (!inferredSQL) return - await testQuery({ value: acceptUntrustedSql(inferredSQL), ...executionCallbacks }) - track('rls_tester_run_query_clicked', { type: 'inferred' }) - } else { - await testQuery({ value, ...executionCallbacks }) - track('rls_tester_run_query_clicked', { type: 'raw' }) - } - } - - const assistantSql = format === 'lib' && inferredSQL ? acceptUntrustedSql(inferredSQL) : value - - const getDebugPrompt = ({ includeSql = false }: { includeSql?: boolean } = {}) => { - const prompt = `Help me fix my RLS policy based on the attached SQL snippet that gave the following error: \n\n${executeSqlError?.message}\n\nEvaluate if the problem might be query first, before checking my RLS policies.` - - return includeSql ? `${prompt}\n\nSQL Query:\n\`\`\`sql\n${assistantSql}\n\`\`\`` : prompt - } - - const onDebugWithAssistant = () => { - const prompt = getDebugPrompt() - openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - aiSnap.newChat({ - name: 'Debug RLS policies', - sqlSnippets: [assistantSql], - initialInput: prompt, - }) - setOpen(false) - } - - useEffect(() => { - if (open) { - setRole({ type: 'postgrest', role: 'anon' }) - } else { - // Flip back to service role - setRole(undefined) - } - }, [open, setRole]) - - return ( - <Sheet open={open} onOpenChange={setOpen}> - <SheetTrigger asChild> - <Button variant="default" icon={<Code />}> - Test - </Button> - </SheetTrigger> - - <SheetContent className="w-[600px]! flex flex-col gap-y-0"> - <SheetHeader> - <SheetTitle className="flex items-center gap-x-4"> - <span>What data can my users see?</span> - <FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER} /> - </SheetTitle> - <SheetDescription> - See what data a user is allowed to read based on your RLS policies - </SheetDescription> - </SheetHeader> - - <div className="grow overflow-y-auto flex flex-col"> - {sandboxEnabled && <SandboxManagement />} - - <SheetSection className="px-0 py-0 border-t"> - <div className="flex flex-col p-5 pt-4 gap-y-4"> - <RoleSelector onSelectRole={setSelectedOption} /> - {selectedOption === 'authenticated' && <UserSelector />} - </div> - - <DialogSectionSeparator /> - - <div className="flex items-center justify-between px-5 py-2"> - <p className="text-sm">Query</p> - <div className="flex items-center gap-x-2"> - <Select - value={format} - onValueChange={(x) => { - const newFormat = x as 'sql' | 'lib' - setFormat(newFormat) - if (newFormat !== 'lib') { - setInferredSQL(undefined) - if (debounceRef.current !== null) clearTimeout(debounceRef.current) - } - }} - > - <SelectTrigger size="tiny"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - <SelectLabel>Query format</SelectLabel> - <SelectItem value="sql">SQL</SelectItem> - <SelectItem value="lib">Client library</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - </div> - </div> - - <div className="h-40 relative"> - <UserSqlEditor - id="rls-tester" - value={value} - placeholder={ - format === 'sql' - ? safeSql`select * from table;` - : safeSql`SQL will be inferred from client library code` - } - onChange={handleValueChange} - actions={{ - runQuery: { - enabled: open, - callback: () => { - if (!isInferring && !isLoading) onRunQuery() - }, - }, - }} - /> - </div> - </SheetSection> - - {format === 'lib' && ( - <div> - <DialogSectionSeparator /> - <InferredSQLViewer sql={inferredSQL} isLoading={isInferring} /> - </div> - )} - - <DialogSectionSeparator /> - - {parseQueryError ? ( - <div className="p-4"> - <Admonition - type="warning" - title="Error parsing query" - description={parseQueryError.message} - /> - </div> - ) : parseClientCodeError ? ( - <div className="p-4"> - <Admonition - type="warning" - title="Error parsing client code" - description={parseClientCodeError.message} - /> - </div> - ) : ( - executeSqlError && ( - <div className="p-4"> - <Admonition - type="warning" - title="Error running SQL query" - description={executeSqlError.message} - actions={[ - <AiAssistantDropdown - key="ai-assistant" - label="Ask Assistant" - telemetrySource="rls_tester" - buildPrompt={() => getDebugPrompt({ includeSql: true })} - onOpenAssistant={onDebugWithAssistant} - />, - ]} - /> - </div> - ) - )} - - {results === null ? ( - !parseQueryError && !parseClientCodeError && !executeSqlError && <RLSTesterEmptyState /> - ) : !!parseQueryResults ? ( - <RLSTesterResults - results={results} - parseQueryResults={parseQueryResults} - autoLimit={autoLimit} - handleSelectEditPolicy={handleSelectEditPolicy} - /> - ) : null} - </div> - - <SheetFooter className="sm:justify-between"> - <Button asChild variant="default" icon={<ExternalLink />}> - <a - target="_blank" - rel="noopener noreferrer" - href="https://github.com/orgs/supabase/discussions/45233" - > - Give feedback - </a> - </Button> - <div className="flex items-center gap-x-2"> - <Button variant="default" disabled={isLoading} onClick={() => setOpen(false)}> - Cancel - </Button> - <Button - variant="primary" - loading={isInferring || isLoading} - disabled={format === 'lib' && !inferredSQL} - onClick={onRunQuery} - > - Run query - </Button> - </div> - </SheetFooter> - </SheetContent> - </Sheet> - ) -} diff --git a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.utils.ts b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.utils.ts deleted file mode 100644 index d6862e5e1cd..00000000000 --- a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import type { ParseSQLQueryResponse } from '@/data/misc/parse-query-mutation' - -export function filterTablePolicies({ - policies, - schema, - table, - role, - operation, -}: { - policies: Policy[] - schema: string - table: string - role: string | undefined - operation: ParseSQLQueryResponse['operation'] -}): Policy[] { - return policies.filter( - (x) => - x.schema === schema && - x.table === table && - (x.roles.includes(role ?? '') || (x.roles.length === 1 && x.roles[0] === 'public')) && - (x.command === 'ALL' || x.command === operation) - ) -} diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index 49fbcf5abd3..ba2dba4f676 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -35,9 +35,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -107,31 +107,31 @@ export const RateLimits = () => { RATE_LIMIT_TOKEN_REFRESH: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an 5 minutes'), + .max(2147483647, 'Must not be more than 2,147,483,647 in 5 minutes'), RATE_LIMIT_VERIFY: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an 5 minutes'), + .max(2147483647, 'Must not be more than 2,147,483,647 in 5 minutes'), RATE_LIMIT_EMAIL_SENT: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_SMS_SENT: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_ANONYMOUS_USERS: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_OTP: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_WEB3: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), }) const rateLimitForm = useForm<z.infer<typeof RateLimitFormSchema>>({ diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx index dc89c65f59e..43137ec556e 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx @@ -80,7 +80,7 @@ describe('AddNewURLModal', () => { expect(toast.success).toHaveBeenCalledWith('Successfully added 1 URL') }) - it('dedupes URLs after normalising a trailing comma before submitting', async () => { + it('normalizes a trailing-comma URL before submitting', async () => { const user = userEvent.setup() mutateMock.mockImplementation((_vars, callbacks) => callbacks?.onSuccess?.()) @@ -88,11 +88,7 @@ describe('AddNewURLModal', () => { await screen.findByRole('dialog') - await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com') - await user.click(screen.getByRole('button', { name: 'Add URL' })) - - const urlInputs = screen.getAllByPlaceholderText('https://mydomain.com') - await user.type(urlInputs[1], 'https://app.example.com,') + await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com,') fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement) @@ -130,4 +126,24 @@ describe('AddNewURLModal', () => { expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument() expect(mutateMock).not.toHaveBeenCalled() }) + + it('rejects a whitespace-padded URL when it already exists in the allow list', async () => { + const user = userEvent.setup() + + render( + <AddNewURLModal visible allowList={['https://existing.example.com']} onClose={vi.fn()} /> + ) + + await screen.findByRole('dialog') + + await user.type( + screen.getByPlaceholderText('https://mydomain.com'), + ' https://existing.example.com ' + ) + + fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement) + + expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument() + expect(mutateMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx index fcd9160d02b..7e7cffa8b6c 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx @@ -22,7 +22,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray' import * as z from 'zod' -import { urlRegex } from '../Auth.constants' +import { normalizeRedirectUrl, parseRedirectUrls, urlRegex } from '../Auth.constants' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' const MAX_URLS_LENGTH = 2 * 1024 @@ -33,61 +33,88 @@ interface AddNewURLModalProps { onClose: () => void } -const normaliseUrl = (value: string) => value.replace(/,\s*$/, '') +const createRedirectUrlsSchema = (normalizedAllowList: string[]) => { + const redirectUrlRegex = urlRegex() + + return z + .object({ + urls: z + .object({ + value: z.string().trim().min(1, 'Please provide a value').transform(normalizeRedirectUrl), + }) + .array() + .default([]), + }) + .superRefine((data, ctx) => { + const seenUrls = new Set<string>() + + data.urls.forEach((url, index) => { + if (!redirectUrlRegex.test(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'Please provide a valid URL', + }) + } + + if (normalizedAllowList.includes(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'URL already exists in the allow list', + }) + } + + if (seenUrls.has(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'URL already exists in this list', + }) + } + + seenUrls.add(url.value) + }) + }) +} export const AddNewURLModal = ({ visible, allowList, onClose }: AddNewURLModalProps) => { const { ref } = useParams() const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation() - const redirectUrlRegex = urlRegex() - - const FormSchema = z.object({ - urls: z - .object({ - value: z - .string() - .min(1, 'Please provide a value') - .refine( - (value) => redirectUrlRegex.test(normaliseUrl(value)), - 'Please provide a valid URL' - ) - .refine((value) => !allowList.includes(normaliseUrl(value)), { - message: 'URL already exists in the allow list', - }), - }) - .array() - .default([]), - }) + const normalizedAllowList = parseRedirectUrls(allowList.join(',')) + const formSchema = createRedirectUrlsSchema(normalizedAllowList) const initialValues = { urls: [{ value: '' }] } - const form = useForm<z.infer<typeof FormSchema>>({ - resolver: zodResolver(FormSchema), + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), defaultValues: initialValues, }) const urls = form.watch('urls') - const onSubmit = (data: z.infer<typeof FormSchema>) => { - const dedupedUrls = [...new Set(data.urls.map((url) => normaliseUrl(url.value)))] - const payloadUrls = allowList.concat(dedupedUrls) - const addedCount = dedupedUrls.length - const payload = payloadUrls.toString() + const onSubmit = (data: z.infer<typeof formSchema>) => { + const payload = parseRedirectUrls( + normalizedAllowList.concat(data.urls.map((url) => url.value)).join(',') + ) + const payloadString = payload.join(',') + const addedCount = payload.length - normalizedAllowList.length - if (payload.length > MAX_URLS_LENGTH) { + if (payloadString.length > MAX_URLS_LENGTH) { return toast.error('Too many redirect URLs, please remove some or try to use wildcards') - } else { - updateAuthConfig( - { projectRef: ref!, config: { URI_ALLOW_LIST: payload } }, - { - onError: (error) => { - toast.error(`Failed to add URL(s): ${error?.message}`) - }, - onSuccess: () => { - toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`) - form.reset(initialValues) - onClose() - }, - } - ) } + + updateAuthConfig( + { projectRef: ref!, config: { URI_ALLOW_LIST: payloadString } }, + { + onError: (error) => { + toast.error(`Failed to add URL(s): ${error?.message}`) + }, + onSuccess: () => { + toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`) + form.reset(initialValues) + onClose() + }, + } + ) } useEffect(() => { diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx index 5819985f817..0441dfab252 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx @@ -23,10 +23,11 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' +import { parseRedirectUrls } from '../Auth.constants' import { AddNewURLModal } from './AddNewURLModal' import { RedirectUrlList } from './RedirectUrlList' import { ValueContainer } from './ValueContainer' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { HorizontalShimmerWithIcon } from '@/components/ui/Shimmers' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' @@ -46,9 +47,7 @@ export const RedirectUrls = () => { useAuthConfigUpdateMutation() const URI_ALLOW_LIST_ARRAY = useMemo(() => { - return authConfig?.URI_ALLOW_LIST - ? authConfig.URI_ALLOW_LIST.split(/\s*[,]+\s*/).filter((url: string) => url) - : [] + return parseRedirectUrls(authConfig?.URI_ALLOW_LIST) }, [authConfig?.URI_ALLOW_LIST]) const [open, setOpen] = useState(false) @@ -58,8 +57,7 @@ export const RedirectUrls = () => { const onConfirmDeleteUrl = async (urls?: string[]) => { if (!urls || urls.length === 0) return - // Remove selectedUrl from array and update - const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !selectedUrls.includes(url)) + const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !urls.includes(url)) const payloadString = payload.join(',') await updateAuthConfig( { projectRef: projectRef!, config: { URI_ALLOW_LIST: payloadString } }, diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx index dc987721b65..a57bdfd515a 100644 --- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx @@ -18,7 +18,6 @@ import { InputGroupText, Switch, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -27,10 +26,11 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx index 84bf7fdcf7f..15ad25aa548 100644 --- a/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx +++ b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx @@ -5,7 +5,6 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Input } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -14,9 +13,10 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index 8672aeb72c9..b2570e70367 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -19,9 +19,10 @@ import { InputGroupText, Switch, } from 'ui' -import { Admonition, PageSection, PageSectionContent } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import * as z from 'zod' import { urlRegex } from '../Auth.constants' diff --git a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx index 7dcc4640d47..4697f0f4aed 100644 --- a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx @@ -29,7 +29,7 @@ import { getIntegrationTypeLabel, INTEGRATION_TYPES, } from './ThirdPartyAuthForm.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { InlineLink } from '@/components/ui/InlineLink' import { useDeleteThirdPartyAuthIntegrationMutation } from '@/data/third-party-auth/integration-delete-mutation' diff --git a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx index 216cf0232c6..12614f426e6 100644 --- a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx @@ -63,7 +63,10 @@ export const SortDropdown = ({ return ( <DropdownMenu> <DropdownMenuTrigger asChild> - <Button icon={sortOrder === 'desc' ? <ArrowDownWideNarrow /> : <ArrowDownNarrowWide />}> + <Button + variant="default" + icon={sortOrder === 'desc' ? <ArrowDownWideNarrow /> : <ArrowDownNarrowWide />} + > Sorted by {sortColumn === 'id' ? 'user ID' : sortColumn.replaceAll('_', ' ')} </Button> </DropdownMenuTrigger> diff --git a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx index ac14ea57465..beaa3fcd4fb 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx @@ -4,8 +4,9 @@ import Link from 'next/link' import { useQueryState } from 'nuqs' import { useEffect } from 'react' import { Button, cn, CriticalIcon, Separator } from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { UserHeader } from './UserHeader' import { PANEL_PADDING } from './Users.constants' diff --git a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx index d13aaf25138..1686bdf5b0f 100644 --- a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx +++ b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx index 144dfe661f6..5d70043695b 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx @@ -10,7 +10,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useOrganizationPaymentMethodDeleteMutation } from '@/data/organizations/organization-payment-method-delete-mutation' import type { OrganizationPaymentMethod } from '@/data/organizations/organization-payment-methods-query' diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx index a8b9da11623..cd4037b16a5 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx @@ -51,7 +51,7 @@ import { getURL } from '@/lib/helpers' export const BillingCustomerDataSchema = z.object({ tax_id_type: z.string(), - tax_id_value: z.string().min(2, { + tax_id_value: z.string().trim().min(2, { message: 'Tax ID needs to be set.', }), tax_id_name: z.string(), @@ -140,7 +140,8 @@ export const NewPaymentMethodElement = forwardRef( form.setValue('tax_id_name', name) } - const { tax_id_name, tax_id_value } = form.watch() + const { tax_id_name, tax_id_value: rawTaxIdValue } = form.watch() + const taxIdValue = rawTaxIdValue?.trim() ?? '' const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null) @@ -149,16 +150,16 @@ export const NewPaymentMethodElement = forwardRef( >(undefined) useEffect(() => { if (!onTaxIdChange) return - if (purchasingAsBusiness && selectedTaxId && tax_id_value) { + if (purchasingAsBusiness && selectedTaxId && taxIdValue) { onTaxIdChange({ country: getEffectiveTaxCountry(selectedTaxId), type: selectedTaxId.type, - value: tax_id_value, + value: taxIdValue, }) } else { onTaxIdChange(null) } - }, [purchasingAsBusiness, selectedTaxId, tax_id_value, onTaxIdChange]) + }, [purchasingAsBusiness, selectedTaxId, taxIdValue, onTaxIdChange]) const addressCountry = stripeAddress?.address.country const availableTaxIds = useMemo(() => { @@ -212,7 +213,7 @@ export const NewPaymentMethodElement = forwardRef( ? { country: getEffectiveTaxCountry(selectedTaxId), type: selectedTaxId.type, - value: form.getValues('tax_id_value'), + value: form.getValues('tax_id_value').trim(), } : null } diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx index d6ce0676d04..85b92a6c18a 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx @@ -17,10 +17,10 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { FormPanel } from '@/components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from '@/components/ui/Forms/FormSection' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import PartnerManagedResource from '@/components/ui/PartnerManagedResource' import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils' import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query' diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx index c51f5dbc1f5..76fd9d3bbdb 100644 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx +++ b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' // [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth interface DiskIOBandwidthWarningsProps { diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 65a65c1c546..0b1e7890e51 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -4,8 +4,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { PropsWithChildren, ReactNode } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { WorkflowLogs } from './WorkflowLogs' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 95cdf11692b..8bd01d65a4d 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -42,7 +42,7 @@ import { } from './BranchManagement.utils' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' diff --git a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx index 452da4ecf0f..7186cf00078 100644 --- a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx +++ b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx @@ -12,7 +12,7 @@ import { AlertDialogTrigger, Button, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useTrack } from '@/lib/telemetry/track' diff --git a/apps/studio/components/interfaces/BranchManagement/ReviewRow.tsx b/apps/studio/components/interfaces/BranchManagement/ReviewRow.tsx deleted file mode 100644 index 8d2382dbc3d..00000000000 --- a/apps/studio/components/interfaces/BranchManagement/ReviewRow.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import dayjs from 'dayjs' -import { MoreVertical, X } from 'lucide-react' -import { useRouter } from 'next/router' -import { toast } from 'sonner' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' - -import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation' -import type { Branch } from '@/data/branches/branches-query' -import { branchKeys } from '@/data/branches/keys' - -interface ReviewRowProps { - branch: Branch -} - -export const ReviewRow = ({ branch }: ReviewRowProps) => { - const router = useRouter() - const queryClient = useQueryClient() - const { project_ref: branchRef, parent_project_ref: projectRef } = branch - - const { mutate: updateBranch, isPending: isUpdating } = useBranchUpdateMutation({ - onSuccess: () => { - toast.success('Branch marked as not ready for review') - queryClient.invalidateQueries({ - queryKey: branchKeys.list(projectRef), - }) - }, - onError: (error) => { - toast.error(`Failed to update branch: ${error.message}`) - }, - }) - - const handleRowClick = () => { - router.push(`/project/${branchRef}/merge`) - } - - const handleNotReadyForReview = (e?: Event) => { - e?.preventDefault() - e?.stopPropagation() - - updateBranch({ - branchRef, - projectRef, - requestReview: false, - }) - } - - const formattedReviewDate = branch.review_requested_at - ? dayjs(branch.review_requested_at).format('MMM DD, YYYY HH:mm') - : '' - - return ( - <div - className="w-full flex items-center justify-between px-6 py-3 hover:bg-surface-100 cursor-pointer transition-colors" - onClick={handleRowClick} - > - <div className="flex items-center gap-x-4"> - <div className="flex gap-4"> - <h4 className="text-sm text-foreground" title={branch.name}> - {branch.name} - </h4> - <p className="text-foreground-light">{formattedReviewDate}</p> - </div> - </div> - - <div className="flex items-center gap-x-2"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="text" - icon={<MoreVertical />} - className="px-1" - onClick={(e) => e.stopPropagation()} - /> - </DropdownMenuTrigger> - <DropdownMenuContent className="w-56" side="bottom" align="end"> - <DropdownMenuItem - className="gap-x-2" - disabled={isUpdating} - onClick={(e) => e.stopPropagation()} - onSelect={handleNotReadyForReview} - > - <X size={14} /> - Not ready for review - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - ) -} diff --git a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx index 3902ce11504..8ead7ed8cd5 100644 --- a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx +++ b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx @@ -14,11 +14,12 @@ import { DialogTrigger, StatusIcon, } from 'ui' -import { GenericSkeletonLoader, TimestampInfo } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ActionStatusBadge, ActionStatusBadgeCondensed, STATUS_TO_LABEL } from './ActionStatusBadge' import BranchStatusBadge from './BranchStatusBadge' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ActionRunData } from '@/data/actions/action-detail-query' import { useActionRunLogsQuery } from '@/data/actions/action-logs-query' import { diff --git a/apps/studio/components/interfaces/Branching/MergeRequest.tsx b/apps/studio/components/interfaces/Branching/MergeRequest.tsx index 681fa079482..a1c1e2ae36b 100644 --- a/apps/studio/components/interfaces/Branching/MergeRequest.tsx +++ b/apps/studio/components/interfaces/Branching/MergeRequest.tsx @@ -12,7 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { useIsPgDeltaDiffEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { ReviewWithAI } from '../BranchManagement/ReviewWithAI' diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index b9825fa2360..5eee915f9dd 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -1,12 +1,11 @@ import { useParams } from 'common' import dynamic from 'next/dynamic' import Link from 'next/link' -import { useMemo, useRef } from 'react' +import { useMemo, useRef, type ComponentType } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { connectSchema } from './connect.schema' import type { ConnectionStringPooler, ConnectState, @@ -129,6 +128,27 @@ function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionSt ) } +// Vite needs `import.meta.glob` to statically discover the step content +// modules because the `${filePath}` template can span multiple directory +// segments (`flask/supabasepy`, `steps/shadcn/explore`, ...) which Vite's +// dynamic-import-vars plugin can't analyze. Skip the glob on the SSR bundle +// — Vite replaces `import.meta.env.SSR` at build time and tree-shakes the +// call so the 37 content modules stay out of the server graph (pulling them +// in reshuffles chunks enough to surface latent circular-dep bugs in +// unrelated modules). Next/webpack doesn't know about `import.meta.glob` +// either; the try/catch lets that branch fall through to the webpack-friendly +// `import()` below. +let contentModules: Record<string, () => Promise<unknown>> = {} +if (!import.meta.env?.SSR) { + try { + contentModules = import.meta.glob('./content/**/content.{tsx,ts}') + } catch { + // webpack build: import.meta.glob is undefined, keep empty map + } +} + +type StepContentModule = { default: ComponentType<StepContentProps> } + /** * Dynamically loads and renders a content component from the content directory. * All step content uses this unified loader - no built-in component registry needed. @@ -151,7 +171,16 @@ function StepContent({ // Dynamically import the content component const ContentComponent = useMemo(() => { - return dynamic<StepContentProps>(() => import(`./content/${filePath}/content`), { + const viteLoader = + contentModules[`./content/${filePath}/content.tsx`] ?? + contentModules[`./content/${filePath}/content.ts`] + + const loader = viteLoader + ? (viteLoader as () => Promise<StepContentModule>) + : () => + import(/* @vite-ignore */ `./content/${filePath}/content`) as Promise<StepContentModule> + + return dynamic<StepContentProps>(loader, { loading: () => ( <div className="p-4 min-h-[200px]"> <GenericSkeletonLoader /> @@ -196,11 +225,6 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS const showSelfHostedMcpNotice = deploymentMode.isSelfHosted && state.mode === 'mcp' - const customPrompt = useMemo( - () => connectSchema.modes.find((m) => m.id === state.mode)?.prompt, - [state.mode] - ) - if (steps.length === 0) return null return ( @@ -244,7 +268,7 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS /> )} - <CopyPromptAdmonition stepsContainerRef={stepsContainerRef} customPrompt={customPrompt} /> + <CopyPromptAdmonition stepsContainerRef={stepsContainerRef} /> <div className="mt-6" ref={stepsContainerRef}> {steps.map((step, index) => ( diff --git a/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx b/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx deleted file mode 100644 index 0039e5bc6bc..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Box, Check, ChevronDown } from 'lucide-react' -import { useState } from 'react' -import { - Button, - cn, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - Popover, - PopoverContent, - PopoverTrigger, -} from 'ui' - -import { ConnectionType } from '@/components/interfaces/ConnectSheet/Connect.constants' -import { ConnectionIcon } from '@/components/interfaces/ConnectSheet/ConnectionIcon' - -interface FrameworkSelectorProps { - value: string - onChange: (value: string) => void - items: ConnectionType[] - className?: string - size?: 'tiny' | 'small' -} - -export const FrameworkSelector = ({ - value, - onChange, - items, - className, - size = 'tiny', -}: FrameworkSelectorProps) => { - const [open, setOpen] = useState(false) - - const selectedItem = items.find((item) => item.key === value) - - function handleSelect(key: string) { - onChange(key) - setOpen(false) - } - - return ( - <Popover open={open} onOpenChange={setOpen} modal={false}> - <div className={cn('flex', className)}> - <PopoverTrigger asChild> - <Button - size={size} - variant="default" - className={cn('gap-0 justify-between', className?.includes('w-full') && 'w-full')} - iconRight={<ChevronDown strokeWidth={1.5} />} - > - <div className="flex items-center gap-2"> - {selectedItem?.icon ? <ConnectionIcon icon={selectedItem.icon} /> : <Box size={12} />} - {selectedItem?.label} - </div> - </Button> - </PopoverTrigger> - </div> - <PopoverContent - className="p-0 w-radix-popover-trigger-width min-w-48" - side="bottom" - align="start" - onOpenAutoFocus={(e) => e.preventDefault()} - > - <Command> - <CommandInput placeholder="Search..." /> - <CommandList> - <CommandEmpty>No results found.</CommandEmpty> - <CommandGroup> - {items.map((item) => ( - <CommandItem - key={item.key} - value={item.key} - onSelect={() => handleSelect(item.key)} - className="flex gap-2 items-center" - > - {item.icon ? <ConnectionIcon icon={item.icon} /> : <Box size={12} />} - {item.label} - <Check - size={15} - className={cn('ml-auto', item.key === value ? 'opacity-100' : 'opacity-0')} - /> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - ) -} diff --git a/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx index c9391840089..43a12e20516 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { EnvRow } from '../common/EnvRow' import { SecretEnvRow } from '../common/SecretRow' diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx index 0c4fc5348bb..85ca7e62f9f 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { Download } from 'lucide-react' import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx index 941f6120b74..f0dec4bbc8b 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx @@ -1,5 +1,5 @@ import { DatabaseBackup } from 'lucide-react' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export const BackupsEmpty = () => { return ( diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index fb3d5f8fc7c..edac39d4c11 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -4,9 +4,9 @@ import { Clock } from 'lucide-react' import { useRouter } from 'next/router' import { useState } from 'react' import { toast } from 'sonner' -import { TimestampInfo } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { BackupItem } from './BackupItem' import { BackupsEmpty } from './BackupsEmpty' diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx index 17fa70ecfdc..63eb2035eb5 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx @@ -1,5 +1,5 @@ import { Badge, Button } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { BackupsEmpty } from '../BackupsEmpty' import Panel from '@/components/ui/Panel' diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index 12f18ffd3a7..0113fd47257 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -15,6 +15,9 @@ import { TableHead, TableHeader, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -23,9 +26,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import CreateEnumeratedTypeSidePanel from './CreateEnumeratedTypeSidePanel' import EditEnumeratedTypeSidePanel from './EditEnumeratedTypeSidePanel' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useEnumeratedTypeDeleteMutation } from '@/data/enumerated-types/enumerated-type-delete-mutation' import { useEnumeratedTypesQuery } from '@/data/enumerated-types/enumerated-types-query' @@ -235,13 +238,19 @@ export const EnumeratedTypes = () => { {!isSchemaLocked && ( <div className="flex justify-end items-center space-x-2"> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="default" - className="px-1" - icon={<MoreVertical />} - /> - </DropdownMenuTrigger> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + variant="default" + className="px-1" + icon={<MoreVertical />} + aria-label={`${type.name} actions`} + /> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">More options</TooltipContent> + </Tooltip> <DropdownMenuContent side="bottom" align="end" className="w-32"> <DropdownMenuItem className="space-x-2" diff --git a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index ff4ead4a268..e9c36ea6bca 100644 --- a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -22,7 +22,7 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx index 11ada34ae1d..4c68836985a 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx @@ -5,7 +5,7 @@ import { useState } from 'react' import { extensions } from 'shared-data' import { toast } from 'sonner' import { Button, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { EnableExtensionModal } from './EnableExtensionModal' diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index 789208cb6d3..c40a04a07e0 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -40,7 +40,7 @@ import { CreateFunctionHeader } from './CreateFunctionHeader' import { FunctionEditor } from './FunctionEditor' import { POSTGRES_DATA_TYPES } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useDatabaseFunctionCreateMutation } from '@/data/database-functions/database-functions-create-mutation' import type { SavedDatabaseFunction } from '@/data/database-functions/database-functions-query' diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index 654b6a08e9b..dd9e837282f 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -15,6 +15,9 @@ import { DropdownMenuTrigger, TableCell, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { stripInArgModePrefixes } from '../Functions.utils' @@ -160,14 +163,19 @@ export const FunctionList = ({ <div className="flex items-center justify-end"> {canUpdateFunctions ? ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="More options" - variant="default" - className="px-1" - icon={<MoreVertical />} - /> - </DropdownMenuTrigger> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + aria-label={`${x.name} actions`} + variant="default" + className="px-1" + icon={<MoreVertical />} + /> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">More options</TooltipContent> + </Tooltip> <DropdownMenuContent side="left" className="w-52"> {isApiDocumentAvailable && ( <> diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 7e75986a65c..d91bbab1f40 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -14,8 +14,8 @@ import { TableHeader, TableRow, } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' @@ -27,9 +27,9 @@ import { selectFilterSchema, } from '@/components/interfaces/Reports/v2/ReportsSelectFilter' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useDatabaseFunctionDeleteMutation } from '@/data/database-functions/database-functions-delete-mutation' diff --git a/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx b/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx index 4d178de6d8f..3c3e3441ab4 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx @@ -8,7 +8,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { HooksListEmpty } from './HooksListEmpty' import { SchemaTable } from './SchemaTable' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' import { NoSearchResults } from '@/components/ui/NoSearchResults' diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index 42f7570cccc..1e5a3657258 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -33,7 +33,7 @@ import { SheetSection, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelector, diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index f0c15d7c805..7c9dc433c26 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -21,9 +21,9 @@ import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringL import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import { CreateIndexSidePanel } from './CreateIndexSidePanel' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useDatabaseIndexDeleteMutation } from '@/data/database-indexes/index-delete-mutation' import { useIndexesQuery, type DatabaseIndex } from '@/data/database-indexes/indexes-query' diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index df17cf08a7e..24f4763fa21 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -16,9 +16,10 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { MigrationsEmptyState } from './MigrationsEmptyState' import { SupportLink } from '@/components/interfaces/Support/SupportLink' diff --git a/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx b/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx index 92862f0dc18..8a6e8ec769b 100644 --- a/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { Terminal } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import CommandRender from '@/components/interfaces/Functions/CommandRender' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts similarity index 97% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts rename to apps/studio/components/interfaces/Database/Policies/Policies.constants.ts index 9e918f6cc11..2e1e7dcb2a7 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts @@ -1,6 +1,18 @@ import { safeSql } from '@supabase/pg-meta/src/pg-format' +import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' -import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants' +export interface PolicyTemplate { + id: string + preview: boolean + templateName: string + description: string + name: string + statement: string + definition: SafeSqlFragment + check: SafeSqlFragment + command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL' + roles: Array<string> +} /** * ---------------------------------------------------------------- diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Database/Policies/Policies.tsx similarity index 97% rename from apps/studio/components/interfaces/Auth/Policies/Policies.tsx rename to apps/studio/components/interfaces/Database/Policies/Policies.tsx index 084d6b57ffe..b841b160ab4 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Database/Policies/Policies.tsx @@ -9,8 +9,8 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PolicyTableRow, PolicyTableRowProps, -} from '@/components/interfaces/Auth/Policies/PolicyTableRow' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +} from '@/components/interfaces/Database/Policies/PolicyTableRow' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning' import { RLSToggleDialog } from '@/components/interfaces/Database/RLSToggleDialog' import { NoSearchResults } from '@/components/ui/NoSearchResults' diff --git a/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx b/apps/studio/components/interfaces/Database/Policies/PoliciesDataContext.tsx similarity index 95% rename from apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx rename to apps/studio/components/interfaces/Database/Policies/PoliciesDataContext.tsx index 15d083b5935..a0cdef263c9 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PoliciesDataContext.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from 'react' import { createContext, useCallback, useContext, useMemo } from 'react' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import type { ResponseError } from '@/types' type TableKey = `${string}.${string}` diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/LockedQuerySection.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/LockedQuerySection.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx similarity index 98% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx index ea7da926e6f..77d54208dad 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx @@ -17,7 +17,7 @@ import { getGeneralPolicyTemplates, getQueuePolicyTemplates, getRealtimePolicyTemplates, -} from '../PolicyEditorModal/PolicyEditorModal.constants' +} from '../Policies.constants' import { Markdown } from '@/components/interfaces/Markdown' import CardButton from '@/components/ui/CardButton' import CopyButton from '@/components/ui/CopyButton' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/QueryError.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/QueryError.tsx diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/RLSCodeEditor.tsx new file mode 100644 index 00000000000..78752078ca5 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/RLSCodeEditor.tsx @@ -0,0 +1,76 @@ +import { Monaco } from '@monaco-editor/react' +import { noop } from 'lodash' +import type { editor } from 'monaco-editor' +import { RefObject, useEffect } from 'react' + +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' + +// [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard? +// Feels like we're creating multiple copies of Editor. I'm keen to make this one the defacto as well so lets make sure +// this component does not have RLS specific logic + +interface RLSCodeEditorProps { + id: string + defaultValue?: string + onInputChange?: (value?: string) => void + wrapperClassName?: string + className?: string + value?: string + placeholder?: string + readOnly?: boolean + + lineNumberStart?: number + onChange?: () => void + onMount?: () => void + + editorRef: RefObject<editor.IStandaloneCodeEditor | null> + monacoRef?: RefObject<Monaco> +} + +export const RLSCodeEditor = ({ + id, + defaultValue, + onInputChange, + wrapperClassName, + className, + value, + placeholder, + readOnly = false, + lineNumberStart, + onChange = noop, + onMount: _onMount = noop, + + editorRef, + monacoRef, +}: RLSCodeEditorProps) => { + const onChangeContent = (value?: string) => { + onChange() + onInputChange?.(value) + } + + // when the value has changed, trigger the onChange callback so that the height of the container can be adjusted. + // Happens when the value wordwraps and is updated via a template. + useEffect(() => onChange(), [value]) + + return ( + <CodeEditor + id={id} + monacoRef={monacoRef} + editorRef={editorRef} + isReadOnly={readOnly} + language="pgsql" + className={className} + wrapperClassName={wrapperClassName} + placeholder={placeholder} + value={value ?? undefined} + defaultValue={defaultValue ?? undefined} + options={{ + lineNumbers: + lineNumberStart !== undefined ? (num) => (num + lineNumberStart).toString() : undefined, + }} + actions={{ placeholderFill: { enabled: false } }} + onMount={_onMount} + onInputChange={onChangeContent} + /> + ) +} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx similarity index 99% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx index 6c13c6756ef..872acd61db3 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx @@ -40,7 +40,7 @@ import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader' import { PolicyTemplates } from './PolicyTemplates' import { QueryError } from './QueryError' import { RLSCodeEditor } from './RLSCodeEditor' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { IStandaloneCodeEditor } from '@/components/interfaces/SQLEditor/SQLEditor.types' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -48,7 +48,7 @@ import { useDatabasePolicyUpdateMutation } from '@/data/database-policies/databa import { databasePoliciesKeys } from '@/data/database-policies/keys' import { QueryResponseError, useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' @@ -375,7 +375,6 @@ export const PolicyEditorPanel = memo(function ({ }} > <RLSCodeEditor - disableTabToUsePlaceholder readOnly={!canUpdatePolicies} id="rls-exp-one-editor" placeholder={ @@ -439,7 +438,6 @@ export const PolicyEditorPanel = memo(function ({ }} > <RLSCodeEditor - disableTabToUsePlaceholder readOnly={!canUpdatePolicies} id="rls-exp-two-editor" placeholder="-- Provide a SQL expression for the with check statement" diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyRow.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyRow.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.types.ts b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.types.ts similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.types.ts rename to apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.types.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.test.ts b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils.test.ts similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.test.ts rename to apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils.test.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts rename to apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx similarity index 92% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index fc4df51adea..36ef1d8dc4d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -56,24 +56,26 @@ export const PolicyTableRowHeader = ({ > <Table strokeWidth={1.5} size={16} className="text-foreground-muted" /> <CardTitle className="m-0 normal-case">{table.name}</CardTitle> + </EditorTablePageLink> + <div className="flex items-center gap-x-1"> {!table.rls_enabled && ( - <Badge variant="warning" className="shrink-0"> + <Badge variant="warning" className="h-5"> RLS Disabled </Badge> )} {!isLoadingApiAccess && !hasApiAccess && ( - <Badge variant="default" className="shrink-0"> + <Badge variant="default" className="h-5"> API Disabled </Badge> )} - </EditorTablePageLink> - {isTableLocked && ( - <Badge> - <span className="flex gap-2 items-center text-xs uppercase text-foreground-lighter"> - <Lock size={12} /> Locked - </span> - </Badge> - )} + {isTableLocked && ( + <Badge className="h-5"> + <span className="flex gap-x-1 items-center text-foreground-lighter"> + <Lock size={10} /> Locked + </span> + </Badge> + )} + </div> </div> {!isTableLocked && ( <div className="flex-1"> diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx similarity index 96% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx index 05c2d9df9c7..2e60637e59b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx @@ -12,7 +12,7 @@ import { TableHeader, TableRow, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { usePoliciesData } from '../PoliciesDataContext' @@ -21,7 +21,7 @@ import type { PolicyTable } from './PolicyTableRow.types' import type { Policy } from './PolicyTableRow.utils' import { getTableAdmonitionMessage, getTableDataApiStatus } from './PolicyTableRow.utils' import { PolicyTableRowHeader } from './PolicyTableRowHeader' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -94,7 +94,9 @@ const PolicyTableRowComponent = ({ return ( <Card className={cn(isPubliclyReadable && 'border-warning-500')}> - <CardHeader className={cn('py-3 px-4', status !== 'secured' && 'border-b-0')}> + <CardHeader + className={cn('py-3 px-4', status !== 'secured' && status !== 'unknown' && 'border-b-0')} + > <PolicyTableRowHeader table={table} isLocked={isLocked} diff --git a/apps/studio/components/interfaces/Database/Privileges/PrivilegesHead.tsx b/apps/studio/components/interfaces/Database/Privileges/PrivilegesHead.tsx index 5402e33184e..32b20038a7c 100644 --- a/apps/studio/components/interfaces/Database/Privileges/PrivilegesHead.tsx +++ b/apps/studio/components/interfaces/Database/Privileges/PrivilegesHead.tsx @@ -8,7 +8,7 @@ import { SelectValue, } from 'ui' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' export interface PrivilegesHeadProps { disabled: boolean diff --git a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx index 18a52c4062a..556c808c992 100644 --- a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx +++ b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx @@ -10,7 +10,7 @@ import { DialogTitle, DialogTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { INTERNAL_SCHEMAS, useIsProtectedSchema } from '@/hooks/useProtectedSchemas' diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx index bc985e144f8..2474a0e9548 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx @@ -22,7 +22,7 @@ import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PublicationSkeleton } from './PublicationSkeleton' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import InformationBox from '@/components/ui/InformationBox' import { NoSearchResults } from '@/components/ui/NoSearchResults' import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query' diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index 60b969f3564..05646721e50 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -3,14 +3,14 @@ import { useParams } from 'common' import { Search } from 'lucide-react' import { useMemo, useRef, useState } from 'react' import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { PublicationTablesSkeleton } from './PublicationSkeleton' import { PublicationsTableItem } from './PublicationsTableItem' import { AlertError } from '@/components/ui/AlertError' import { NoSearchResults } from '@/components/ui/NoSearchResults' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query' import { useTablesQuery } from '@/data/tables/tables-query' @@ -113,9 +113,9 @@ export const PublicationsTables = () => { <TableRow> <TableHead>Name</TableHead> <TableHead className="hidden lg:table-cell">Description</TableHead> - {/* - We've disabled All tables toggle for publications. - See https://github.com/supabase/supabase/pull/7233. + {/* + We've disabled All tables toggle for publications. + See https://github.com/supabase/supabase/pull/7233. */} <TableHead /> </TableRow> diff --git a/apps/studio/components/interfaces/Auth/RLSTester/InferredSQLViewer.tsx b/apps/studio/components/interfaces/Database/RLSTester/InferredSQLViewer.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/RLSTester/InferredSQLViewer.tsx rename to apps/studio/components/interfaces/Database/RLSTester/InferredSQLViewer.tsx diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTableCard.tsx b/apps/studio/components/interfaces/Database/RLSTester/RLSTableCard.tsx similarity index 75% rename from apps/studio/components/interfaces/Auth/RLSTester/RLSTableCard.tsx rename to apps/studio/components/interfaces/Database/RLSTester/RLSTableCard.tsx index e1a9da41bea..724f8b57cf2 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTableCard.tsx +++ b/apps/studio/components/interfaces/Database/RLSTester/RLSTableCard.tsx @@ -2,20 +2,25 @@ import { Check, ChevronDown, Edit, X } from 'lucide-react' import { useMemo } from 'react' import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger, WarningIcon } from 'ui' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { type ParseSQLQueryOperations } from '@/data/misc/parse-query-mutation' interface RLSTableCardProps { table: { schema: string; name: string; isRLSEnabled: boolean } + operation: ParseSQLQueryOperations role?: string policies: Policy[] + hasError: boolean handleSelectEditPolicy: (policy: Policy) => void } export const RLSTableCard = ({ table, + operation, role, policies, + hasError, handleSelectEditPolicy, }: RLSTableCardProps) => { const { schema, name, isRLSEnabled } = table @@ -37,8 +42,11 @@ export const RLSTableCard = ({ return ( <p> RLS is enabled but no policies exist for the{' '} - <code className="text-code-inline">{role}</code> role on this table - no data will be - returned. + <code className="text-code-inline">{role}</code> role on this table -{' '} + {operation === 'SELECT' + ? 'no data will be returned' + : `no data will be ${operation?.toLowerCase()}${operation?.toLowerCase().endsWith('e') ? 'd' : 'ed'}`} + . </p> ) } @@ -54,6 +62,7 @@ export const RLSTableCard = ({ </p> <TableAccessPolicySummary policies={policies} + operation={operation} handleSelectEditPolicy={handleSelectEditPolicy} /> </> @@ -71,6 +80,7 @@ export const RLSTableCard = ({ </p> <TableAccessPolicySummary policies={policies} + operation={operation} handleSelectEditPolicy={handleSelectEditPolicy} /> </> @@ -81,11 +91,14 @@ export const RLSTableCard = ({ <> <p> {policies.length} {policies.length > 1 ? 'policies apply' : 'policy applies'} for the{' '} - <code className="text-code-inline">{role}</code> role on this table. Only rows that match{' '} - {policies.length > 1 ? 'these conditions' : 'this condition'} are returned. + <code className="text-code-inline">{role}</code> role on this table.{' '} + {operation === 'SELECT' + ? `Only rows that match ${policies.length > 1 ? 'these conditions' : 'this condition'} are returned.` + : `The ${operation} operation will only be successful if the conditions are matched.`} </p> <TableAccessPolicySummary policies={policies} + operation={operation} handleSelectEditPolicy={handleSelectEditPolicy} /> </> @@ -97,6 +110,7 @@ export const RLSTableCard = ({ falseOnlyPolicy, policies, role, + operation, handleSelectEditPolicy, ]) @@ -109,7 +123,7 @@ export const RLSTableCard = ({ <div className="flex items-center gap-x-2"> {!isRLSEnabled ? ( <WarningIcon /> - ) : noPolicies || falseOnlyPolicy ? ( + ) : (hasError && operation === 'INSERT') || noPolicies || falseOnlyPolicy ? ( <X size={16} className="text-destructive" /> ) : ( <Check size={16} className="text-brand" /> @@ -120,18 +134,20 @@ export const RLSTableCard = ({ </div> </div> <div className="flex items-center gap-x-2"> - <p - className={cn( - 'text-xs text-foreground-light w-max', - !isRLSEnabled && 'text-foreground' - )} - > - {noPolicies || falseOnlyPolicy - ? 'Returns no rows' - : !isRLSEnabled || !!trueOnlyPolicy - ? 'Returns all rows' - : null} - </p> + {operation === 'SELECT' && ( + <p + className={cn( + 'text-xs text-foreground-light w-max', + !isRLSEnabled && 'text-foreground' + )} + > + {noPolicies || falseOnlyPolicy + ? 'Returns no rows' + : !isRLSEnabled || !!trueOnlyPolicy + ? 'Returns all rows' + : null} + </p> + )} <ChevronDown className="transition-transform duration-200" strokeWidth={1.5} size={14} /> </div> </CollapsibleTrigger> @@ -149,9 +165,11 @@ export const RLSTableCard = ({ const TableAccessPolicySummary = ({ policies, + operation, handleSelectEditPolicy, }: { policies: Policy[] + operation: ParseSQLQueryOperations handleSelectEditPolicy: (policy: Policy) => void }) => { return ( @@ -165,8 +183,11 @@ const TableAccessPolicySummary = ({ <div> <p>{policy.name}</p> <p className="text-foreground-lighter"> - Show rows where:{' '} - <code className="text-code-inline text-foreground">{policy.definition}</code> + {operation === 'SELECT' ? 'Show rows' : `Allow ${operation?.toLocaleLowerCase()}s`}{' '} + where:{' '} + <code className="text-code-inline text-foreground"> + {policy.definition ?? policy.check} + </code> </p> </div> <ButtonTooltip diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTester.types.ts b/apps/studio/components/interfaces/Database/RLSTester/RLSTester.types.ts similarity index 77% rename from apps/studio/components/interfaces/Auth/RLSTester/RLSTester.types.ts rename to apps/studio/components/interfaces/Database/RLSTester/RLSTester.types.ts index b7495d6d48f..2948c48f477 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTester.types.ts +++ b/apps/studio/components/interfaces/Database/RLSTester/RLSTester.types.ts @@ -1,4 +1,4 @@ -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { type User } from '@/data/auth/users-infinite-query' import { type ParseSQLQueryResponse } from '@/data/misc/parse-query-mutation' diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterEmptyState.tsx b/apps/studio/components/interfaces/Database/RLSTester/RLSTesterEmptyState.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/RLSTester/RLSTesterEmptyState.tsx rename to apps/studio/components/interfaces/Database/RLSTester/RLSTesterEmptyState.tsx diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterResults.tsx b/apps/studio/components/interfaces/Database/RLSTester/RLSTesterResults.tsx similarity index 72% rename from apps/studio/components/interfaces/Auth/RLSTester/RLSTesterResults.tsx rename to apps/studio/components/interfaces/Database/RLSTester/RLSTesterResults.tsx index 1820acf17a2..12d2a2b8f22 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterResults.tsx +++ b/apps/studio/components/interfaces/Database/RLSTester/RLSTesterResults.tsx @@ -6,19 +6,21 @@ import { TabsList_Shadcn_, TabsTrigger_Shadcn_, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Results } from '../../SQLEditor/UtilityPanel/Results' import { RLSTableCard } from './RLSTableCard' import { ParseQueryResults } from './RLSTester.types' import { deriveRLSTestState } from './RLSTesterResults.utils' import { useTestQueryRLS } from './useTestQueryRLS' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import { type QueryResponseError } from '@/data/sql/execute-sql-mutation' interface RLSTesterResultsProps { results: Object[] autoLimit: boolean parseQueryResults: ParseQueryResults + executeSqlError: Error | QueryResponseError | null | undefined handleSelectEditPolicy: (policy: Policy) => void } @@ -26,6 +28,7 @@ export const RLSTesterResults = ({ results, autoLimit, parseQueryResults, + executeSqlError, handleSelectEditPolicy, }: RLSTesterResultsProps) => { const { limit } = useTestQueryRLS() @@ -34,14 +37,19 @@ export const RLSTesterResults = ({ isServiceRole, tableWithRLSEnabledButNoPolicies, tableWithRLSEnabledWithPolicyFalse, + tableWithRLSEnabledWithPoliciesDontApply, noAccessToData, } = deriveRLSTestState(parseQueryResults) + const { operation, role } = parseQueryResults + const rlsBlockInsert = executeSqlError && operation === 'INSERT' + const noAccess = noAccessToData || rlsBlockInsert + return ( <div className="p-5 pt-4"> <div className="flex items-center gap-x-2 mb-2"> <p className="text-sm">Summary</p> - {noAccessToData ? ( + {noAccess ? ( <Badge variant="destructive">No access</Badge> ) : ( <Badge variant="success">{results.length > 0 ? 'Can access' : 'Has access'}</Badge> @@ -53,7 +61,7 @@ export const RLSTesterResults = ({ <TabsTrigger_Shadcn_ value="policies" className="px-2"> Policies applied </TabsTrigger_Shadcn_> - <TabsTrigger_Shadcn_ value="data" className="px-2"> + <TabsTrigger_Shadcn_ value="data" className="px-2" disabled={operation !== 'SELECT'}> Data preview </TabsTrigger_Shadcn_> </TabsList_Shadcn_> @@ -84,7 +92,13 @@ export const RLSTesterResults = ({ {!isServiceRole && (!!tableWithRLSEnabledButNoPolicies ? ( <Admonition showIcon={false} type="default" className="rounded-sm mt-2"> - <p className="mb-0.5!">This user has no access to any rows from this query</p> + <p className="mb-0.5! text-foreground"> + This user{' '} + {operation === 'SELECT' + ? 'has no access to any rows' + : `is unable to ${operation?.toLowerCase()} any rows`}{' '} + from this query + </p> <p className="text-foreground-light"> The table{' '} <code className="text-code-inline"> @@ -98,7 +112,9 @@ export const RLSTesterResults = ({ </Admonition> ) : tableWithRLSEnabledWithPolicyFalse ? ( <Admonition showIcon={false} type="default" className="rounded-sm mt-2"> - <p className="mb-0.5!">This user has no access to any rows from this query</p> + <p className="mb-0.5! text-foreground"> + This user has no access to any rows from this query + </p> <p className="text-foreground-light"> The table{' '} <code className="text-code-inline"> @@ -111,11 +127,29 @@ export const RLSTesterResults = ({ role. </p> </Admonition> + ) : rlsBlockInsert && + parseQueryResults.user && + tableWithRLSEnabledWithPoliciesDontApply ? ( + <Admonition showIcon={false} type="default" className="rounded-sm mt-2"> + <p className="mb-0.5! text-foreground"> + This user is unable to {operation?.toLowerCase()} any rows from this query + </p> + <p className="text-foreground-light"> + The table{' '} + <code className="text-code-inline"> + {tableWithRLSEnabledWithPoliciesDontApply.schema}. + {tableWithRLSEnabledWithPoliciesDontApply.table} + </code>{' '} + has a policy for the{' '} + <code className="text-code-inline break-keep!">{parseQueryResults.role}</code>{' '} + role, but its condition wasn't satisfied for this specific request. + </p> + </Admonition> ) : null)} {isServiceRole && ( <Admonition showIcon={false} type="default" className="rounded-sm mt-2"> - <p className="mb-0.5!"> + <p className="mb-0.5! text-foreground"> The <code className="text-code-inline">postgres</code> role has access to all rows for this query </p> @@ -136,8 +170,10 @@ export const RLSTesterResults = ({ <RLSTableCard key={`${schema}.${table}`} table={{ schema, name: table, isRLSEnabled }} - role={parseQueryResults.role} + role={role} + operation={operation} policies={tablePolicies} + hasError={!!executeSqlError} handleSelectEditPolicy={handleSelectEditPolicy} /> ) diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterResults.utils.ts b/apps/studio/components/interfaces/Database/RLSTester/RLSTesterResults.utils.ts similarity index 80% rename from apps/studio/components/interfaces/Auth/RLSTester/RLSTesterResults.utils.ts rename to apps/studio/components/interfaces/Database/RLSTester/RLSTesterResults.utils.ts index 05977e58eb5..5f0e43025c6 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterResults.utils.ts +++ b/apps/studio/components/interfaces/Database/RLSTester/RLSTesterResults.utils.ts @@ -8,6 +8,10 @@ export function deriveRLSTestState(parseQueryResults: ParseQueryResults | undefi const tableWithRLSEnabledWithPolicyFalse = parseQueryResults?.tables.find( (x) => x.isRLSEnabled && x.tablePolicies.some((y) => y.definition === 'false') ) + const tableWithRLSEnabledWithPoliciesDontApply = parseQueryResults?.tables.find( + (x) => x.isRLSEnabled && x.tablePolicies.length !== 0 + ) + const noAccessToData = !isServiceRole && (!!tableWithRLSEnabledButNoPolicies || !!tableWithRLSEnabledWithPolicyFalse) @@ -15,6 +19,7 @@ export function deriveRLSTestState(parseQueryResults: ParseQueryResults | undefi isServiceRole, tableWithRLSEnabledButNoPolicies, tableWithRLSEnabledWithPolicyFalse, + tableWithRLSEnabledWithPoliciesDontApply, noAccessToData, } } diff --git a/apps/studio/components/interfaces/Database/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Database/RLSTester/RLSTesterSheet.tsx new file mode 100644 index 00000000000..d5bcc5ed0b7 --- /dev/null +++ b/apps/studio/components/interfaces/Database/RLSTester/RLSTesterSheet.tsx @@ -0,0 +1,387 @@ +import { + acceptUntrustedSql, + safeSql, + type SafeSqlFragment, + type UntrustedSqlFragment, +} from '@supabase/pg-meta' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@ui/components/shadcn/ui/select' +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { Code, ExternalLink } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { + Button, + DialogSectionSeparator, + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + SheetTrigger, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { InferredSQLViewer } from './InferredSQLViewer' +import { type ParseQueryResults } from './RLSTester.types' +import { RLSTesterEmptyState } from './RLSTesterEmptyState' +import { RLSTesterResults } from './RLSTesterResults' +import { RoleSelector } from './RoleSelector' +import { SandboxManagement } from './SandboxManagement' +import { UserSelector } from './UserSelector' +import { UserSqlEditor } from './UserSqlEditor' +import { useTestQueryRLS, type TestQueryBlockedReason } from './useTestQueryRLS' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' +import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' +import { useTrack } from '@/lib/telemetry/track' +import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { PostgresSandboxProvider, usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' +import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' + +interface RLSTesterSheetProps { + handleSelectEditPolicy: (policy: Policy) => void +} + +export const RLSTesterSheet = (props: RLSTesterSheetProps) => { + return ( + <PostgresSandboxProvider> + <RLSTesterSheetContents {...props} /> + </PostgresSandboxProvider> + ) +} + +const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { + const track = useTrack() + const aiSnap = useAiAssistantStateSnapshot() + const { openSidebar } = useSidebarManagerSnapshot() + const { setRole } = useRoleImpersonationStateSnapshot() + const { startSandbox, status, isSyncing } = usePostgresSandbox() + + const sandboxEnabled = useFlag('rlsTesterSandbox') + const sandboxIsStarting = status === 'loading' + + const [open, setOpen] = useState(false) + const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon') + const [blockedReason, setBlockedReason] = useState<TestQueryBlockedReason>() + + const [format, setFormat] = useState<'sql' | 'lib'>('sql') + const [inferredSQL, setInferredSQL] = useState<UntrustedSqlFragment>() + + const [value, setValue] = useState<SafeSqlFragment>(safeSql``) + const [results, setResults] = useState<Object[] | null>(null) + const [autoLimit, setAutoLimit] = useState(false) + const [parseQueryResults, setParseQueryResults] = useState<ParseQueryResults>() + + const { + testQuery, + inferSQLFromLib, + isLoading, + isInferring, + executeSqlError, + parseQueryError, + parseClientCodeError, + } = useTestQueryRLS() + const isErrorDueToRLS = + executeSqlError?.message.includes('violates row-level security policy') ?? false + const mutationOperation = blockedReason?.type === 'mutation' ? blockedReason.operation : undefined + + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) + + const handleValueChange = (sql: SafeSqlFragment) => { + setValue(sql) + if (format !== 'lib') return + + if (debounceRef.current !== null) clearTimeout(debounceRef.current) + if (!sql) return + + debounceRef.current = setTimeout(() => inferSQLFromLib(sql, setInferredSQL), 1500) + } + + const executionCallbacks = { + option: selectedOption, + acknowledgeMutation: blockedReason?.type === 'mutation', + onExecuteSQL: ({ result, isAutoLimit }: { result: Object[] | null; isAutoLimit: boolean }) => { + setResults(result) + setAutoLimit(isAutoLimit) + }, + onParseQuery: setParseQueryResults, + onValidationBlocked: setBlockedReason, + } + + const onRunQuery = async () => { + setBlockedReason(undefined) + + if (format === 'lib') { + if (!inferredSQL) return + const blocked = await testQuery({ + value: acceptUntrustedSql(inferredSQL), + ...executionCallbacks, + }) + if (!blocked) track('rls_tester_run_query_clicked', { type: 'inferred' }) + } else { + const blocked = await testQuery({ value, ...executionCallbacks }) + if (!blocked) track('rls_tester_run_query_clicked', { type: 'raw' }) + } + } + + const assistantSql = format === 'lib' && inferredSQL ? acceptUntrustedSql(inferredSQL) : value + + const getDebugPrompt = ({ includeSql = false }: { includeSql?: boolean } = {}) => { + const prompt = `Help me fix my RLS policy based on the attached SQL snippet that gave the following error: \n\n${executeSqlError?.message}\n\nEvaluate if the problem might be query first, before checking my RLS policies.` + + return includeSql ? `${prompt}\n\nSQL Query:\n\`\`\`sql\n${assistantSql}\n\`\`\`` : prompt + } + + const onDebugWithAssistant = () => { + const prompt = getDebugPrompt() + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ + name: 'Debug RLS policies', + sqlSnippets: [assistantSql], + initialInput: prompt, + }) + setOpen(false) + } + + useEffect(() => { + setRole({ type: 'postgrest', role: 'anon' }) + return () => { + // Flip back to service role + setRole(undefined) + } + // [Joshen] Intentional - to only reset back to service role when navigating away + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + <> + <Sheet open={open} onOpenChange={setOpen}> + <SheetTrigger asChild> + <Button variant="default" icon={<Code />}> + Test + </Button> + </SheetTrigger> + + <SheetContent className="w-[600px]! flex flex-col gap-y-0"> + <SheetHeader> + <SheetTitle className="flex items-center gap-x-4"> + <span>What data can my users access?</span> + <FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER} /> + </SheetTitle> + <SheetDescription> + See what data a user is allowed to read or modify based on your RLS policies + </SheetDescription> + </SheetHeader> + + <div className="grow overflow-y-auto flex flex-col"> + {sandboxEnabled && <SandboxManagement />} + + <SheetSection className="px-0 py-0 border-t"> + <div className="flex flex-col p-5 pt-4 gap-y-4"> + <RoleSelector onSelectRole={setSelectedOption} /> + {selectedOption === 'authenticated' && <UserSelector />} + </div> + + <DialogSectionSeparator /> + + <div className="flex items-center justify-between px-5 py-2"> + <p className="text-sm">Query</p> + <div className="flex items-center gap-x-2"> + <Select + value={format} + onValueChange={(x) => { + const newFormat = x as 'sql' | 'lib' + setFormat(newFormat) + if (newFormat !== 'lib') { + setInferredSQL(undefined) + if (debounceRef.current !== null) clearTimeout(debounceRef.current) + } + }} + > + <SelectTrigger size="tiny"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectLabel>Query format</SelectLabel> + <SelectItem value="sql">SQL</SelectItem> + <SelectItem value="lib">Client library</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </div> + </div> + + <div className="h-40 relative"> + <UserSqlEditor + id="rls-tester" + value={value} + placeholder={ + format === 'sql' + ? safeSql`select * from table;` + : safeSql`SQL will be inferred from client library code` + } + onChange={handleValueChange} + actions={{ + runQuery: { + enabled: open, + callback: () => { + if (!isInferring && !isLoading) onRunQuery() + }, + }, + }} + /> + </div> + </SheetSection> + + {format === 'lib' && ( + <div> + <DialogSectionSeparator /> + <InferredSQLViewer sql={inferredSQL} isLoading={isInferring} /> + </div> + )} + + <DialogSectionSeparator /> + + {blockedReason?.type === 'multiple-statements' ? ( + <div className="p-4"> + <Admonition + type="warning" + title="Only a single SQL statement is supported" + description="Remove any additional statements and run the query again." + /> + </div> + ) : blockedReason?.type === 'unsupported-operation' ? ( + <div className="p-4"> + <Admonition + type="warning" + title={`${blockedReason.operation} queries are not supported by the RLS Tester yet`} + description="Support for testing UPDATE and DELETE statements will be available soon." + /> + </div> + ) : parseQueryError ? ( + <div className="p-4"> + <Admonition + type="warning" + title="Error parsing query" + description={parseQueryError.message} + /> + </div> + ) : parseClientCodeError ? ( + <div className="p-4"> + <Admonition + type="warning" + title="Error parsing client code" + description={parseClientCodeError.message} + /> + </div> + ) : executeSqlError && !isErrorDueToRLS ? ( + <div className="p-4"> + <Admonition + type="warning" + title="Error running SQL query" + description={executeSqlError.message} + actions={[ + <AiAssistantDropdown + key="ai-assistant" + label="Ask Assistant" + telemetrySource="rls_tester" + buildPrompt={() => getDebugPrompt({ includeSql: true })} + onOpenAssistant={onDebugWithAssistant} + />, + ]} + /> + </div> + ) : isLoading ? ( + <div className="p-4"> + <GenericSkeletonLoader /> + </div> + ) : results === null && !isErrorDueToRLS ? ( + <RLSTesterEmptyState /> + ) : !!parseQueryResults ? ( + <RLSTesterResults + results={results ?? []} + parseQueryResults={parseQueryResults} + autoLimit={autoLimit} + executeSqlError={executeSqlError} + handleSelectEditPolicy={handleSelectEditPolicy} + /> + ) : null} + </div> + + <SheetFooter className="sm:justify-between"> + <Button asChild variant="default" icon={<ExternalLink />}> + <a + target="_blank" + rel="noopener noreferrer" + href="https://github.com/orgs/supabase/discussions/45233" + > + Give feedback + </a> + </Button> + <div className="flex items-center gap-x-2"> + <Button variant="default" disabled={isLoading} onClick={() => setOpen(false)}> + Cancel + </Button> + <Button + variant="primary" + loading={isInferring || isLoading} + disabled={(format === 'lib' && !inferredSQL) || sandboxIsStarting || isSyncing} + onClick={onRunQuery} + > + Run query + </Button> + </div> + </SheetFooter> + </SheetContent> + </Sheet> + + <ConfirmationModal + visible={!!mutationOperation} + variant="warning" + size="medium" + loading={isLoading} + title="Confirm to run this query" + confirmLabel="Run query" + onConfirm={onRunQuery} + onCancel={() => setBlockedReason(undefined)} + alert={{ + title: `This ${mutationOperation} query will run against your actual database`, + description: 'Your database may be directly modified as a result. Are you sure?', + }} + > + {sandboxEnabled && ( + <> + <p className="text-sm"> + We highly recommend using the sandbox to set up an ephemeral database environment for + testing insert, update, or delete queries. + </p> + <Button + variant="default" + className="mt-2" + onClick={() => { + startSandbox() + setBlockedReason(undefined) + }} + > + Set up sandbox + </Button> + </> + )} + </ConfirmationModal> + </> + ) +} diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RoleSelector.tsx b/apps/studio/components/interfaces/Database/RLSTester/RoleSelector.tsx similarity index 89% rename from apps/studio/components/interfaces/Auth/RLSTester/RoleSelector.tsx rename to apps/studio/components/interfaces/Database/RLSTester/RoleSelector.tsx index b25aab9d137..0d23453b36b 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RoleSelector.tsx +++ b/apps/studio/components/interfaces/Database/RLSTester/RoleSelector.tsx @@ -11,7 +11,7 @@ export const RoleSelector = ({ onSelectRole }: RoleSelectorProps) => { const { role, setRole } = useRoleImpersonationStateSnapshot() return ( - <FormItemLayout isReactForm={false} label="Test as"> + <FormItemLayout isReactForm={false} label="Test query as" layout="horizontal"> <RadioGroupStacked defaultValue={role?.role ?? 'anon'}> <RadioGroupStackedItem value="anon" @@ -27,7 +27,7 @@ export const RoleSelector = ({ onSelectRole }: RoleSelectorProps) => { value="authenticated" id="authenticated" label="Authenticated user" - description="A specific logged in user" + description="A logged in user" onClick={() => { onSelectRole('authenticated') }} diff --git a/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx b/apps/studio/components/interfaces/Database/RLSTester/SandboxManagement.tsx similarity index 93% rename from apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx rename to apps/studio/components/interfaces/Database/RLSTester/SandboxManagement.tsx index 6e367139c36..1200d4d361e 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx +++ b/apps/studio/components/interfaces/Database/RLSTester/SandboxManagement.tsx @@ -1,6 +1,6 @@ import { Box, Loader2, LogOut, RefreshCw } from 'lucide-react' import { Badge, Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' @@ -14,15 +14,15 @@ export const SandboxManagement = () => { <Admonition type="default" layout="horizontal" - className="min-h-min border-none [&>div>div>div>div>p]:!mb-0" + className="min-h-min border-none [&>div>div>div>div>p]:!mb-0 [&>div>div]:gap-x-2" actions={[ - <Button key="set-up" variant="default" onClick={() => startSandbox()}> + <Button key="sandbox" variant="default" onClick={() => startSandbox()}> Set up sandbox </Button>, ]} > <div className="flex items-center gap-x-2"> - <p className="text-foreground !m-0">Set up sandbox for testing</p> + <p className="text-foreground !m-0">Run queries in a sandbox</p> <Badge variant="success">Recommended</Badge> </div> <p className="text-foreground-light !m-0"> diff --git a/apps/studio/components/interfaces/Auth/RLSTester/UserSelector.tsx b/apps/studio/components/interfaces/Database/RLSTester/UserSelector.tsx similarity index 87% rename from apps/studio/components/interfaces/Auth/RLSTester/UserSelector.tsx rename to apps/studio/components/interfaces/Database/RLSTester/UserSelector.tsx index 1d2a88544b2..c949c2f3fe0 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/UserSelector.tsx +++ b/apps/studio/components/interfaces/Database/RLSTester/UserSelector.tsx @@ -12,13 +12,15 @@ import { CommandInput, CommandItem, CommandList, + copyToClipboard, Popover, PopoverContent, PopoverTrigger, ScrollArea, } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -66,7 +68,27 @@ export const UserSelector = () => { } return ( - <FormItemLayout isReactForm={false} label="Select which user to test as"> + <FormItemLayout + isReactForm={false} + layout="horizontal" + label="Select which user to test as" + description={ + impersonatingUser ? ( + <p> + ID:{' '} + <code + className="text-code-inline cursor-pointer" + onClick={() => { + copyToClipboard(impersonatingUser?.id ?? '') + toast('Copied ID to clipboard') + }} + > + {impersonatingUser.id} + </code> + </p> + ) : undefined + } + > <Popover open={open} onOpenChange={setOpen} modal> <PopoverTrigger asChild> <Button diff --git a/apps/studio/components/interfaces/Auth/RLSTester/UserSqlEditor.tsx b/apps/studio/components/interfaces/Database/RLSTester/UserSqlEditor.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/RLSTester/UserSqlEditor.tsx rename to apps/studio/components/interfaces/Database/RLSTester/UserSqlEditor.tsx diff --git a/apps/studio/components/interfaces/Auth/RLSTester/__tests__/RLSTesterResults.test.tsx b/apps/studio/components/interfaces/Database/RLSTester/__tests__/RLSTesterResults.test.tsx similarity index 93% rename from apps/studio/components/interfaces/Auth/RLSTester/__tests__/RLSTesterResults.test.tsx rename to apps/studio/components/interfaces/Database/RLSTester/__tests__/RLSTesterResults.test.tsx index b4c4be6f599..8ee7cda9bc8 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/__tests__/RLSTesterResults.test.tsx +++ b/apps/studio/components/interfaces/Database/RLSTester/__tests__/RLSTesterResults.test.tsx @@ -2,16 +2,16 @@ import type { SafeSqlFragment } from '@supabase/pg-meta' import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import type { ParseQueryResults } from '@/components/interfaces/Auth/RLSTester/RLSTester.types' -import { RLSTesterResults } from '@/components/interfaces/Auth/RLSTester/RLSTesterResults' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { ParseQueryResults } from '@/components/interfaces/Database/RLSTester/RLSTester.types' +import { RLSTesterResults } from '@/components/interfaces/Database/RLSTester/RLSTesterResults' import { render } from '@/tests/helpers' -vi.mock('@/components/interfaces/Auth/RLSTester/useTestQueryRLS', () => ({ +vi.mock('@/components/interfaces/Database/RLSTester/useTestQueryRLS', () => ({ useTestQueryRLS: () => ({ limit: 100 }), })) -vi.mock('@/components/interfaces/Auth/RLSTester/RLSTableCard', () => ({ +vi.mock('@/components/interfaces/Database/RLSTester/RLSTableCard', () => ({ RLSTableCard: () => <div data-testid="rls-table-card" />, })) @@ -37,6 +37,7 @@ const makeTable = ( const defaultProps = { results: [], autoLimit: false, + executeSqlError: undefined, handleSelectEditPolicy: vi.fn(), } diff --git a/apps/studio/components/interfaces/Auth/RLSTester/__tests__/RLSTesterResults.utils.test.ts b/apps/studio/components/interfaces/Database/RLSTester/__tests__/RLSTesterResults.utils.test.ts similarity index 96% rename from apps/studio/components/interfaces/Auth/RLSTester/__tests__/RLSTesterResults.utils.test.ts rename to apps/studio/components/interfaces/Database/RLSTester/__tests__/RLSTesterResults.utils.test.ts index 2f18105da5e..3dff5da045f 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/__tests__/RLSTesterResults.utils.test.ts +++ b/apps/studio/components/interfaces/Database/RLSTester/__tests__/RLSTesterResults.utils.test.ts @@ -1,9 +1,9 @@ import type { SafeSqlFragment } from '@supabase/pg-meta' import { describe, expect, it } from 'vitest' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import type { ParseQueryResults } from '@/components/interfaces/Auth/RLSTester/RLSTester.types' -import { deriveRLSTestState } from '@/components/interfaces/Auth/RLSTester/RLSTesterResults.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { ParseQueryResults } from '@/components/interfaces/Database/RLSTester/RLSTester.types' +import { deriveRLSTestState } from '@/components/interfaces/Database/RLSTester/RLSTesterResults.utils' const sql = (s: string) => s as unknown as SafeSqlFragment diff --git a/apps/studio/components/interfaces/Auth/RLSTester/__tests__/useTestQueryRLS.utils.test.ts b/apps/studio/components/interfaces/Database/RLSTester/__tests__/useTestQueryRLS.utils.test.ts similarity index 55% rename from apps/studio/components/interfaces/Auth/RLSTester/__tests__/useTestQueryRLS.utils.test.ts rename to apps/studio/components/interfaces/Database/RLSTester/__tests__/useTestQueryRLS.utils.test.ts index adbfc577685..a5ccadf1c6d 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/__tests__/useTestQueryRLS.utils.test.ts +++ b/apps/studio/components/interfaces/Database/RLSTester/__tests__/useTestQueryRLS.utils.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import { filterTablePolicies } from '@/components/interfaces/Auth/RLSTester/useTestQueryRLS.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import { + filterTablePolicies, + getTestQueryBlockedReason, +} from '@/components/interfaces/Database/RLSTester/useTestQueryRLS.utils' const makePolicy = (overrides: Partial<Policy>): Policy => ({ @@ -145,3 +148,118 @@ describe('filterTablePolicies', () => { }) }) }) + +describe('getTestQueryBlockedReason', () => { + const blockedBase = { + statementCount: 1, + operation: 'SELECT' as const, + hasSandbox: false, + acknowledgeMutation: false, + } + + describe('multiple statements', () => { + it('blocks when statementCount is greater than 1', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, statementCount: 2 })).toStrictEqual({ + type: 'multiple-statements', + }) + }) + + it('takes priority over an unsupported operation', () => { + expect( + getTestQueryBlockedReason({ ...blockedBase, statementCount: 2, operation: 'DELETE' }) + ).toStrictEqual({ type: 'multiple-statements' }) + }) + + it('takes priority over an unacknowledged mutation', () => { + expect( + getTestQueryBlockedReason({ ...blockedBase, statementCount: 2, operation: 'INSERT' }) + ).toStrictEqual({ type: 'multiple-statements' }) + }) + + it('does not block when statementCount is exactly 1', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, statementCount: 1 })).toBeUndefined() + }) + + it('does not block when statementCount is 0', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, statementCount: 0 })).toBeUndefined() + }) + }) + + describe('unsupported operations', () => { + it('blocks UPDATE', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'UPDATE' })).toStrictEqual({ + type: 'unsupported-operation', + operation: 'UPDATE', + }) + }) + + it('blocks DELETE', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'DELETE' })).toStrictEqual({ + type: 'unsupported-operation', + operation: 'DELETE', + }) + }) + + it('blocks UPDATE even with a sandbox available', () => { + expect( + getTestQueryBlockedReason({ ...blockedBase, operation: 'UPDATE', hasSandbox: true }) + ).toStrictEqual({ type: 'unsupported-operation', operation: 'UPDATE' }) + }) + + it('blocks DELETE even when already acknowledged', () => { + expect( + getTestQueryBlockedReason({ + ...blockedBase, + operation: 'DELETE', + acknowledgeMutation: true, + }) + ).toStrictEqual({ type: 'unsupported-operation', operation: 'DELETE' }) + }) + }) + + describe('INSERT mutation warning', () => { + it('blocks an unacknowledged INSERT with no sandbox', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'INSERT' })).toStrictEqual({ + type: 'mutation', + operation: 'INSERT', + }) + }) + + it('does not block an INSERT when a sandbox is available', () => { + expect( + getTestQueryBlockedReason({ ...blockedBase, operation: 'INSERT', hasSandbox: true }) + ).toBeUndefined() + }) + + it('does not block an INSERT once acknowledged', () => { + expect( + getTestQueryBlockedReason({ + ...blockedBase, + operation: 'INSERT', + acknowledgeMutation: true, + }) + ).toBeUndefined() + }) + + it('does not require acknowledgement when a sandbox is available', () => { + expect( + getTestQueryBlockedReason({ + ...blockedBase, + operation: 'INSERT', + hasSandbox: true, + acknowledgeMutation: false, + }) + ).toBeUndefined() + }) + }) + + describe('unblocked operations', () => { + it('does not block SELECT', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'SELECT' })).toBeUndefined() + }) + + it('does not block when operation is undefined', () => { + expect(getTestQueryBlockedReason({ ...blockedBase, operation: undefined })).toBeUndefined() + }) + }) +}) diff --git a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts b/apps/studio/components/interfaces/Database/RLSTester/useTestQueryRLS.ts similarity index 60% rename from apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts rename to apps/studio/components/interfaces/Database/RLSTester/useTestQueryRLS.ts index 91ede81b5a0..c94328db294 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts +++ b/apps/studio/components/interfaces/Database/RLSTester/useTestQueryRLS.ts @@ -1,14 +1,21 @@ -import { type SafeSqlFragment, type UntrustedSqlFragment } from '@supabase/pg-meta' +import { safeSql, type SafeSqlFragment, type UntrustedSqlFragment } from '@supabase/pg-meta' import { useState } from 'react' import { toast } from 'sonner' import { checkIfAppendLimitRequired, suffixWithLimit } from '../../SQLEditor/SQLEditor.utils' import { type ParseQueryResults } from './RLSTester.types' -import { filterTablePolicies } from './useTestQueryRLS.utils' +import { + filterTablePolicies, + getTestQueryBlockedReason, + type TestQueryBlockedReason, +} from './useTestQueryRLS.utils' import { useParseClientCodeMutation } from '@/data/ai/parse-client-code-mutation' import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' import { useCheckTableRLSStatusMutation } from '@/data/database/table-check-rls-mutation' -import { useParseSQLQueryMutation } from '@/data/misc/parse-query-mutation' +import { + useParseSQLQueryMutation, + type ParseSQLQueryOperations, +} from '@/data/misc/parse-query-mutation' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' @@ -19,9 +26,31 @@ import { useImpersonatedUser, useRoleImpersonationStateSnapshot, } from '@/state/role-impersonation-state' +import { type ResponseError } from '@/types' const limit = 100 +export type { TestQueryBlockedReason } + +// [Joshen] Pre-requisite work for identifying UPDATE / DELETE failures due to RLS - not yet wired +// in since those operations are currently blocked (see TestQueryBlockedReason's 'unsupported- +// operation'). Exported so it isn't flagged as unused until the follow-up PR wires it back in. +export const wrapReturnRowsAffected = (sql: SafeSqlFragment) => { + return safeSql` + DO $$ +DECLARE + row_count integer; +BEGIN + ${sql}${(sql.endsWith(';') ? '' : ';') as SafeSqlFragment} + GET DIAGNOSTICS row_count = ROW_COUNT; + -- store it somewhere you can read back + PERFORM set_config('rls_tester.rows_affected', row_count::text, true); +END $$; + +SELECT current_setting('rls_tester.rows_affected', true); +` +} + /** * [Joshen] Testing a SQL query for its RLS access involves 3 async steps * 0. (Optional) Inferring client library code to SQL query via the AI Assistant @@ -83,27 +112,41 @@ export const useTestQueryRLS = () => { onError: () => {}, }) + /** + * Returns true if the query was blocked (multiple statements, or an unacknowledged mutation) + * and did not run, false if it ran (successfully or not) + */ const testQuery = async ({ value, option, + acknowledgeMutation = false, onExecuteSQL, onParseQuery, + onValidationBlocked, }: { value: SafeSqlFragment option: 'anon' | 'authenticated' + acknowledgeMutation?: boolean onExecuteSQL: ({ result, + operation, isAutoLimit, }: { result: Object[] | null + operation: ParseSQLQueryOperations isAutoLimit: boolean }) => void onParseQuery: (results?: ParseQueryResults) => void - }) => { - if (!project) return console.error('Project is required') + onValidationBlocked: (reason: TestQueryBlockedReason) => void + }): Promise<boolean> => { + if (!project) { + console.error('Project is required') + return true + } if (option === 'authenticated' && !user) { - return toast('Select which user to test as before running the query') + toast('Select which user to test as before running the query') + return true } try { @@ -114,8 +157,15 @@ export const useTestQueryRLS = () => { const formattedSql = suffixWithLimit(value, limit) const data = await parseQuery({ sql: formattedSql }) - if (data.operation !== 'SELECT') { - return toast('Only SELECT statements are supported with the RLS Tester at the moment') + const blockedReason = getTestQueryBlockedReason({ + statementCount: data.statementCount, + operation: data.operation, + hasSandbox: !!sandbox, + acknowledgeMutation, + }) + if (blockedReason) { + onValidationBlocked(blockedReason) + return true } const formattedTables = data.tables.map((x) => { @@ -151,39 +201,50 @@ export const useTestQueryRLS = () => { }) const autoLimit = appendAutoLimit ? limit : undefined + // UPDATE/DELETE are blocked above, so wrapReturnRowsAffected isn't wired in here yet - + // it's kept for the follow-up PR that adds proper UPDATE/DELETE support const sql = wrapWithRoleImpersonation(formattedSql, impersonatedRoleState) - const { result } = sandbox - ? await sandbox.run({ sql }).catch((e) => { - setSandboxError(e instanceof Error ? e : new Error(String(e))) - throw e - }) - : await executeSql({ - sql, - autoLimit, - projectRef: project.ref, - connectionString: project.connectionString, - isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), - isStatementTimeoutDisabled: true, - handleError: (e) => { + try { + const { result } = sandbox + ? await sandbox.run({ sql }).catch((e) => { + setSandboxError(e instanceof Error ? e : new Error(String(e))) throw e - }, - queryKey: ['rls-tester'], - }) - onExecuteSQL({ result, isAutoLimit: !!autoLimit }) + }) + : await executeSql({ + sql, + autoLimit, + projectRef: project.ref, + connectionString: project.connectionString, + isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), + isStatementTimeoutDisabled: true, + handleError: (e) => { + throw e + }, + queryKey: ['rls-tester'], + }) - onParseQuery({ - tables, - operation: data.operation, - role: role?.role, - user, - }) + onExecuteSQL({ result, operation: data.operation, isAutoLimit: !!autoLimit }) + onParseQuery({ tables, operation: data.operation, role: role?.role, user }) + } catch (error) { + const isRLSInsertError = Boolean( + (error as ResponseError)?.message?.includes('new row violates row-level security policy') + ) + onExecuteSQL({ result: null, operation: data.operation, isAutoLimit: false }) + if (isRLSInsertError) { + onParseQuery({ tables, operation: data.operation, role: role?.role, user }) + } else { + onParseQuery(undefined) + } + } } catch (error) { - onExecuteSQL({ result: null, isAutoLimit: false }) + onExecuteSQL({ result: null, operation: undefined, isAutoLimit: false }) onParseQuery(undefined) } finally { setIsLoading(false) } + + return false } return { diff --git a/apps/studio/components/interfaces/Database/RLSTester/useTestQueryRLS.utils.ts b/apps/studio/components/interfaces/Database/RLSTester/useTestQueryRLS.utils.ts new file mode 100644 index 00000000000..b0e698dc43b --- /dev/null +++ b/apps/studio/components/interfaces/Database/RLSTester/useTestQueryRLS.utils.ts @@ -0,0 +1,56 @@ +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { ParseSQLQueryResponse } from '@/data/misc/parse-query-mutation' + +export type TestQueryBlockedReason = + | { type: 'multiple-statements' } + | { type: 'unsupported-operation'; operation: 'UPDATE' | 'DELETE' } + | { type: 'mutation'; operation: 'INSERT' } + +/** + * Decides whether a query should be blocked from running, and why. Checked in this order: + * multiple statements first (regardless of operation), then unsupported operations (UPDATE/ + * DELETE aren't testable yet - RLS blocks them silently instead of raising an error), then + * INSERT mutations against the real database that haven't been acknowledged yet. + */ +export function getTestQueryBlockedReason({ + statementCount, + operation, + hasSandbox, + acknowledgeMutation, +}: { + statementCount: number + operation: ParseSQLQueryResponse['operation'] + hasSandbox: boolean + acknowledgeMutation: boolean +}): TestQueryBlockedReason | undefined { + if (statementCount > 1) return { type: 'multiple-statements' } + if (operation === 'UPDATE' || operation === 'DELETE') { + return { type: 'unsupported-operation', operation } + } + if (operation === 'INSERT' && !hasSandbox && !acknowledgeMutation) { + return { type: 'mutation', operation } + } + return undefined +} + +export function filterTablePolicies({ + policies, + schema, + table, + role, + operation, +}: { + policies: Policy[] + schema: string + table: string + role: string | undefined + operation: ParseSQLQueryResponse['operation'] +}): Policy[] { + return policies.filter( + (x) => + x.schema === schema && + x.table === table && + (x.roles.includes(role ?? '') || (x.roles.length === 1 && x.roles[0] === 'public')) && + (x.command === 'ALL' || x.command === operation) + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx index 0fddb5a6b18..11bcb9ece73 100644 --- a/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx @@ -24,8 +24,8 @@ export const DeleteDestination = ({ confirmLabel={isLoading ? 'Deleting...' : `Delete destination`} confirmPlaceholder="Type in name of destination" confirmString={name ?? 'Unknown'} - text={`This will delete the destination "${name}".`} - alert={{ title: 'You cannot recover this destination once deleted.' }} + text={`This will delete the destination "${name}" and stop its replication pipeline.`} + alert={{ title: 'You cannot recover this destination or pipeline once deleted.' }} onCancel={() => setVisible(!visible)} onConfirm={onDelete} /> diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/AnalyticsBucket.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/AnalyticsBucket.utils.ts index 3fe1f083c9a..f516a8ba8ec 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/AnalyticsBucket.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/AnalyticsBucket.utils.ts @@ -14,6 +14,11 @@ export type AnalyticsBucketValidationIssue = { message: string } +type AnalyticsBucketValidationOptions = { + secretsOptional?: boolean + storedS3AccessKeyId?: string +} + // Fields that are always required regardless of the namespace / access key selections. const ANALYTICS_BUCKET_REQUIRED_FIELDS: AnalyticsBucketValidationIssue[] = [ { path: 'warehouseName', message: 'Bucket is required' }, @@ -22,9 +27,13 @@ const ANALYTICS_BUCKET_REQUIRED_FIELDS: AnalyticsBucketValidationIssue[] = [ ] export const getAnalyticsBucketValidationIssues = ( - data: Pick<DestinationPanelSchemaType, AnalyticsBucketFieldPath> + data: Pick<DestinationPanelSchemaType, AnalyticsBucketFieldPath>, + options: AnalyticsBucketValidationOptions = {} ): AnalyticsBucketValidationIssue[] => { - const issues = ANALYTICS_BUCKET_REQUIRED_FIELDS.filter(({ path }) => !data[path]?.trim().length) + const requiredFields = options.secretsOptional + ? ANALYTICS_BUCKET_REQUIRED_FIELDS.filter(({ path }) => path !== 's3AccessKeyId') + : ANALYTICS_BUCKET_REQUIRED_FIELDS + const issues = requiredFields.filter(({ path }) => !data[path]?.trim().length) const isCreatingNewNamespace = data.namespace === CREATE_NEW_NAMESPACE const hasValidNamespace = @@ -39,8 +48,27 @@ export const getAnalyticsBucketValidationIssues = ( ) } + if ( + options.secretsOptional && + data.s3SecretAccessKey?.trim().length && + !data.s3AccessKeyId?.trim().length + ) { + issues.push({ path: 's3AccessKeyId', message: 'S3 Access Key ID is required' }) + } + + const currentS3AccessKeyId = data.s3AccessKeyId?.trim() + const storedS3AccessKeyId = options.storedS3AccessKeyId?.trim() + const hasChangedStoredS3AccessKey = + options.secretsOptional && + !!currentS3AccessKeyId && + currentS3AccessKeyId !== storedS3AccessKeyId + // Creating a new key generates the secret later, so only require it for existing keys. - if (data.s3AccessKeyId !== CREATE_NEW_KEY && !data.s3SecretAccessKey?.trim().length) { + if ( + data.s3AccessKeyId !== CREATE_NEW_KEY && + (!options.secretsOptional || hasChangedStoredS3AccessKey) && + !data.s3SecretAccessKey?.trim().length + ) { issues.push({ path: 's3SecretAccessKey', message: 'S3 Secret Access Key is required' }) } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx index ffb4ce2ecd7..1c744ce031b 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx @@ -16,11 +16,15 @@ import { SelectTrigger, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { CREATE_NEW_KEY, CREATE_NEW_NAMESPACE } from '../DestinationForm.constants' +import { + CREATE_NEW_KEY, + CREATE_NEW_NAMESPACE, + STORED_SECRET_PLACEHOLDER, +} from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' import { InlineLink } from '@/components/ui/InlineLink' import { useAPIKeys } from '@/data/api-keys/api-keys-query' @@ -38,12 +42,27 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' * Ideal scenario: Just select an access key ID, we then apply the secret access key in the PATCH request, so FE has no * context of the secret access key at any point */ +const getS3AccessKeyTriggerLabel = ({ + value, + editMode, +}: { + value: string | undefined + editMode: boolean +}) => { + if (value === CREATE_NEW_KEY) return 'Create a new key' + if (!value) return editMode ? STORED_SECRET_PLACEHOLDER : 'Select an access key ID' + + return value +} + export const AnalyticsBucketFields = ({ form, + editMode, setIsFormInteracting, onSelectNewBucket, }: { form: UseFormReturn<DestinationPanelSchemaType> + editMode: boolean setIsFormInteracting: (value: boolean) => void onSelectNewBucket: () => void }) => { @@ -262,19 +281,23 @@ export const AnalyticsBucketFields = ({ layout="horizontal" label="Catalog Token" description={ - <> - Automatically retrieved from your project's{' '} - <InlineLink href={`/project/${projectRef}/settings/api-keys`}> - service role key - </InlineLink> - </> + editMode ? ( + 'Stored catalog token is hidden and kept automatically.' + ) : ( + <> + Automatically retrieved from your project's{' '} + <InlineLink href={`/project/${projectRef}/settings/api-keys`}> + service role key + </InlineLink> + </> + ) } > <PasswordInput disabled value={field.value} type={showCatalogToken ? 'text' : 'password'} - placeholder="Auto-populated" + placeholder={editMode ? STORED_SECRET_PLACEHOLDER : 'Auto-populated'} actions={ serviceApiKey ? ( <div className="flex items-center justify-center"> @@ -356,11 +379,7 @@ export const AnalyticsBucketFields = ({ <FormControl> <Select value={field.value} onValueChange={field.onChange}> <SelectTrigger> - {field.value === CREATE_NEW_KEY - ? 'Create a new key' - : (field.value ?? '').length === 0 - ? 'Select an access key ID' - : field.value} + {getS3AccessKeyTriggerLabel({ value: field.value, editMode })} </SelectTrigger> <SelectContent> <SelectGroup> @@ -392,14 +411,20 @@ export const AnalyticsBucketFields = ({ layout="horizontal" label="S3 Secret Access Key" className="relative" - description="The secret key corresponding to your selected access key ID." + description={ + editMode + ? 'Stored secret access key is hidden. Enter a new secret to replace it.' + : 'The secret key corresponding to your selected access key ID.' + } > <FormControl> <Input {...field} type={showSecretAccessKey ? 'text' : 'password'} value={field.value ?? ''} - placeholder="Provide the secret access key" + placeholder={ + editMode ? STORED_SECRET_PLACEHOLDER : 'Provide the secret access key' + } /> </FormControl> <Button diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts index 717c51ddc89..dc3a7a78672 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts @@ -14,6 +14,11 @@ const BIGQUERY_REQUIRED_FIELDS: { path: BigQueryFieldPath; message: string }[] = ] export const getBigQueryValidationIssues = ( - data: Pick<DestinationPanelSchemaType, BigQueryFieldPath> + data: Pick<DestinationPanelSchemaType, BigQueryFieldPath>, + options: { secretsOptional?: boolean } = {} ): BigQueryValidationIssue[] => - BIGQUERY_REQUIRED_FIELDS.filter(({ path }) => !data[path]?.trim().length) + BIGQUERY_REQUIRED_FIELDS.filter(({ path }) => { + if (options.secretsOptional && path === 'serviceAccountKey') return false + + return !data[path]?.trim().length + }) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx index 96955cf7615..ec22267509b 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx @@ -2,9 +2,16 @@ import type { UseFormReturn } from 'react-hook-form' import { FormControl, FormField, Input, TextArea } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' -export const BigQueryFields = ({ form }: { form: UseFormReturn<DestinationPanelSchemaType> }) => { +export const BigQueryFields = ({ + form, + editMode, +}: { + form: UseFormReturn<DestinationPanelSchemaType> + editMode: boolean +}) => { return ( <div className="flex flex-col gap-y-6 p-5"> <p className="text-sm font-medium text-foreground">BigQuery settings</p> @@ -48,14 +55,22 @@ export const BigQueryFields = ({ form }: { form: UseFormReturn<DestinationPanelS <FormItemLayout layout="horizontal" label="Service Account Key" - description="Service account credentials JSON for authenticating with BigQuery" + description={ + editMode + ? 'Stored credentials are hidden. Enter new credentials to replace them.' + : 'Service account credentials JSON for authenticating with BigQuery' + } > <FormControl> <TextArea {...field} rows={5} maxLength={5000} - placeholder='{"type": "service_account", "project_id": "...", ...}' + placeholder={ + editMode + ? STORED_SECRET_PLACEHOLDER + : '{"type": "service_account", "project_id": "...", ...}' + } className="font-mono text-xs" /> </FormControl> diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.constants.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.constants.ts index c94f883cd0e..8cadf85301e 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.constants.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.constants.ts @@ -3,3 +3,5 @@ export const CREATE_NEW_KEY = 'create-new' // Hardcoded value for `namespace` field in the form to indicate creating a new namespace export const CREATE_NEW_NAMESPACE = 'create-new-namespace' + +export const STORED_SECRET_PLACEHOLDER = '••••••••••••••••' diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts index 0493ac3a1c3..a97b2120626 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts @@ -71,12 +71,12 @@ const baseSnowflakeFormData = { ducklakeS3UseSsl: undefined, ducklakeMetadataSchema: undefined, snowflakeAccountId: ' MYORG-MYACCOUNT ', - snowflakeUser: ' ETL_USER ', + snowflakeUser: ' PIPELINES_USER ', snowflakePrivateKey: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----', snowflakePrivateKeyPassphrase: ' secret passphrase ', snowflakeDatabase: ' ANALYTICS ', snowflakeSchema: ' PUBLIC ', - snowflakeRole: ' ETL_ROLE ', + snowflakeRole: ' PIPELINES_ROLE ', } describe('DestinationForm.utils DuckLake', () => { @@ -157,6 +157,42 @@ describe('DestinationForm.utils DuckLake', () => { ]) }) + it('allows omitted DuckLake secrets in edit mode', () => { + const issues = getDucklakeValidationIssues( + { + ducklakeCatalogUrl: '', + ducklakeDataPath: 's3://bucket/path', + ducklakeS3AccessKeyId: '', + ducklakeS3SecretAccessKey: '', + ducklakeS3Region: 'eu-west-1', + ducklakeS3Endpoint: 's3.example.com', + ducklakeMetadataSchema: '', + }, + { secretsOptional: true } + ) + + expect(issues).toEqual([]) + }) + + it('requires both DuckLake S3 key fields when replacing one secret in edit mode', () => { + const issues = getDucklakeValidationIssues( + { + ducklakeCatalogUrl: '', + ducklakeDataPath: 's3://bucket/path', + ducklakeS3AccessKeyId: 'access-key', + ducklakeS3SecretAccessKey: '', + ducklakeS3Region: 'eu-west-1', + ducklakeS3Endpoint: 's3.example.com', + ducklakeMetadataSchema: '', + }, + { secretsOptional: true } + ) + + expect(issues).toEqual([ + { path: 'ducklakeS3SecretAccessKey', message: 'S3 Secret Access Key is required' }, + ]) + }) + it('treats whitespace-only values as missing', () => { const issues = getDucklakeValidationIssues({ ducklakeCatalogUrl: ' ', @@ -333,7 +369,7 @@ describe('DestinationForm.utils Snowflake', () => { expect(config).toEqual({ snowflake: { accountId: 'MYORG-MYACCOUNT', - user: 'ETL_USER', + user: 'PIPELINES_USER', privateKey: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----', privateKeyPassphrase: undefined, database: 'ANALYTICS', @@ -358,12 +394,12 @@ describe('DestinationForm.utils Snowflake', () => { expect(config).toEqual({ snowflake: { accountId: 'MYORG-MYACCOUNT', - user: 'ETL_USER', + user: 'PIPELINES_USER', privateKey: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----', privateKeyPassphrase: ' secret passphrase ', database: 'ANALYTICS', schema: 'PUBLIC', - role: 'ETL_ROLE', + role: 'PIPELINES_ROLE', }, }) expect(createS3AccessKey).not.toHaveBeenCalled() @@ -387,6 +423,21 @@ describe('DestinationForm.utils Snowflake', () => { { path: 'snowflakeSchema', message: 'Schema is required' }, ]) }) + + it('allows an omitted Snowflake private key in edit mode', () => { + const issues = getSnowflakeValidationIssues( + { + snowflakeAccountId: 'MYORG-MYACCOUNT', + snowflakeUser: 'PIPELINES_USER', + snowflakePrivateKey: '', + snowflakeDatabase: 'ANALYTICS', + snowflakeSchema: 'PUBLIC', + }, + { secretsOptional: true } + ) + + expect(issues).toEqual([]) + }) }) describe('DestinationForm.utils BigQuery', () => { @@ -427,6 +478,19 @@ describe('DestinationForm.utils BigQuery', () => { expect(issues).toEqual([]) }) + + it('allows an omitted BigQuery service account key in edit mode', () => { + const issues = getBigQueryValidationIssues( + { + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: '', + }, + { secretsOptional: true } + ) + + expect(issues).toEqual([]) + }) }) describe('DestinationForm.utils Analytics Bucket', () => { @@ -494,6 +558,56 @@ describe('DestinationForm.utils Analytics Bucket', () => { expect(issues).toEqual([]) }) + it('allows omitted S3 key fields in edit mode', () => { + const issues = getAnalyticsBucketValidationIssues( + { + warehouseName: 'bucket', + namespace: 'analytics', + newNamespaceName: '', + s3Region: 'us-east-1', + s3AccessKeyId: '', + s3SecretAccessKey: '', + }, + { secretsOptional: true } + ) + + expect(issues).toEqual([]) + }) + + it('allows an omitted S3 secret for an unchanged Analytics Bucket key in edit mode', () => { + const issues = getAnalyticsBucketValidationIssues( + { + warehouseName: 'bucket', + namespace: 'analytics', + newNamespaceName: '', + s3Region: 'us-east-1', + s3AccessKeyId: 'stored-key', + s3SecretAccessKey: '', + }, + { secretsOptional: true, storedS3AccessKeyId: 'stored-key' } + ) + + expect(issues).toEqual([]) + }) + + it('requires an S3 secret when replacing an Analytics Bucket key in edit mode', () => { + const issues = getAnalyticsBucketValidationIssues( + { + warehouseName: 'bucket', + namespace: 'analytics', + newNamespaceName: '', + s3Region: 'us-east-1', + s3AccessKeyId: 'new-key', + s3SecretAccessKey: '', + }, + { secretsOptional: true, storedS3AccessKeyId: 'stored-key' } + ) + + expect(issues).toEqual([ + { path: 's3SecretAccessKey', message: 'S3 Secret Access Key is required' }, + ]) + }) + it('returns no issues for a complete configuration', () => { const issues = getAnalyticsBucketValidationIssues({ warehouseName: 'bucket', diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/DuckLake.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/DuckLake.utils.ts index 881222f25eb..5ce2046942f 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/DuckLake.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/DuckLake.utils.ts @@ -2,7 +2,7 @@ import { type DestinationPanelSchemaType } from '../DestinationForm.schema' import { DUCKLAKE_MODE_SUPABASE } from './DuckLake.constants' export type DucklakeApiConfig = { - catalog_url: string + catalog_url?: string data_path: string pool_size?: number s3_access_key_id?: string @@ -69,6 +69,12 @@ const DUCKLAKE_CUSTOM_REQUIRED_FIELDS: DucklakeValidationIssue[] = [ { path: 'ducklakeS3Endpoint', message: 'S3 Endpoint is required' }, ] +const DUCKLAKE_CUSTOM_SECRET_FIELDS = new Set<DucklakeFieldPath>([ + 'ducklakeCatalogUrl', + 'ducklakeS3AccessKeyId', + 'ducklakeS3SecretAccessKey', +]) + // Catalog metadata schema is optional, but must be a valid Postgres identifier when set. const METADATA_SCHEMA_PATTERN = /^[A-Za-z0-9_]+$/ const METADATA_SCHEMA_ISSUE: DucklakeValidationIssue = { @@ -82,7 +88,8 @@ const getMissingRequiredFieldIssues = ( ) => requiredFields.filter(({ path }) => !data[path]?.trim().length) export const getDucklakeValidationIssues = ( - data: DucklakeValidationData + data: DucklakeValidationData, + options: { secretsOptional?: boolean } = {} ): DucklakeValidationIssue[] => { if (data.ducklakeMode === DUCKLAKE_MODE_SUPABASE) { const issues = getMissingRequiredFieldIssues(data, DUCKLAKE_SUPABASE_REQUIRED_FIELDS) @@ -94,7 +101,32 @@ export const getDucklakeValidationIssues = ( return issues } - const issues = getMissingRequiredFieldIssues(data, DUCKLAKE_CUSTOM_REQUIRED_FIELDS) + const requiredFields = options.secretsOptional + ? DUCKLAKE_CUSTOM_REQUIRED_FIELDS.filter(({ path }) => !DUCKLAKE_CUSTOM_SECRET_FIELDS.has(path)) + : DUCKLAKE_CUSTOM_REQUIRED_FIELDS + const issues = getMissingRequiredFieldIssues(data, requiredFields) + + if ( + options.secretsOptional && + data.ducklakeS3SecretAccessKey?.trim().length && + !data.ducklakeS3AccessKeyId?.trim().length + ) { + issues.push({ + path: 'ducklakeS3AccessKeyId', + message: 'S3 Access Key ID is required', + }) + } + + if ( + options.secretsOptional && + data.ducklakeS3AccessKeyId?.trim().length && + !data.ducklakeS3SecretAccessKey?.trim().length + ) { + issues.push({ + path: 'ducklakeS3SecretAccessKey', + message: 'S3 Secret Access Key is required', + }) + } // Format checks only apply once a value is present; missing values are already flagged above. if ( diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx index ad1648eaa9c..21c32a6ca44 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx @@ -22,10 +22,11 @@ import { SelectTrigger, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' import { DUCKLAKE_MODE_CUSTOM, @@ -358,7 +359,13 @@ const DuckLakeSupabaseFields = ({ form }: { form: UseFormReturn<DestinationPanel ) } -const DuckLakeCustomFields = ({ form }: { form: UseFormReturn<DestinationPanelSchemaType> }) => { +const DuckLakeCustomFields = ({ + form, + editMode, +}: { + form: UseFormReturn<DestinationPanelSchemaType> + editMode: boolean +}) => { const [showCatalogUrl, setShowCatalogUrl] = useState(false) const [showSecretAccessKey, setShowSecretAccessKey] = useState(false) @@ -379,13 +386,21 @@ const DuckLakeCustomFields = ({ form }: { form: UseFormReturn<DestinationPanelSc <FormItemLayout layout="horizontal" label="Catalog URL" - description="A PostgreSQL connection string for the DuckLake catalog" + description={ + editMode + ? 'Stored catalog URL is hidden. Enter a new URL to replace it.' + : 'A PostgreSQL connection string for the DuckLake catalog' + } > <FormControl> <PasswordInput value={field.value ?? ''} type={showCatalogUrl ? 'text' : 'password'} - placeholder="postgres://user:pass@host:5432/ducklake_catalog" + placeholder={ + editMode + ? STORED_SECRET_PLACEHOLDER + : 'postgres://user:pass@host:5432/ducklake_catalog' + } onChange={(event) => field.onChange(event.target.value)} actions={ <div className="flex items-center justify-center"> @@ -462,10 +477,18 @@ const DuckLakeCustomFields = ({ form }: { form: UseFormReturn<DestinationPanelSc <FormItemLayout layout="horizontal" label="S3 Access Key ID" - description="Required access key ID for the object storage provider" + description={ + editMode + ? 'Stored access key ID is hidden. Enter a new key ID to replace it.' + : 'Required access key ID for the object storage provider' + } > <FormControl> - <Input {...field} placeholder="my-access-key" value={field.value ?? ''} /> + <Input + {...field} + placeholder={editMode ? STORED_SECRET_PLACEHOLDER : 'my-access-key'} + value={field.value ?? ''} + /> </FormControl> </FormItemLayout> )} @@ -478,14 +501,18 @@ const DuckLakeCustomFields = ({ form }: { form: UseFormReturn<DestinationPanelSc <FormItemLayout layout="horizontal" label="S3 Secret Access Key" - description="Required secret access key for the object storage provider" + description={ + editMode + ? 'Stored secret access key is hidden. Enter a new secret to replace it.' + : 'Required secret access key for the object storage provider' + } className="relative" > <FormControl> <Input {...field} type={showSecretAccessKey ? 'text' : 'password'} - placeholder="my-secret-key" + placeholder={editMode ? STORED_SECRET_PLACEHOLDER : 'my-secret-key'} value={field.value ?? ''} /> </FormControl> @@ -641,7 +668,7 @@ export const DuckLakeFields = ({ {effectiveMode === DUCKLAKE_MODE_SUPABASE ? ( <DuckLakeSupabaseFields form={form} /> ) : ( - <DuckLakeCustomFields form={form} /> + <DuckLakeCustomFields form={form} editMode={editMode} /> )} </div> ) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx index ba0f19e5198..4bbfaa2dc49 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx @@ -1,5 +1,7 @@ import { DatabaseZap } from 'lucide-react' +import { InlineLink } from '@/components/ui/InlineLink' + export const NoDestinationsAvailable = () => { return ( <div className="flex flex-col items-center justify-center h-full px-8 py-16 text-center"> @@ -8,8 +10,9 @@ export const NoDestinationsAvailable = () => { </div> <h3 className="text-lg font-medium text-foreground mb-2">No destinations available</h3> <p className="text-sm text-foreground-light max-w-lg"> - Replication destinations are not currently enabled for this project. Contact support to - enable real-time replication to external destinations. + No Pipelines destinations are available for this project yet. Request{' '} + <InlineLink href="https://forms.supabase.com/pg_replicate">alpha access</InlineLink> to use + managed replication pipelines. </p> </div> ) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx index 050921886cf..db4c7ae7563 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { useMemo } from 'react' import type { UseFormReturn } from 'react-hook-form' import { FormControl, FormField } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import type { DestinationPanelSchemaType } from './DestinationForm.schema' diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx index 4d17fb10313..b5eaccceef8 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx @@ -5,9 +5,16 @@ import { Button, FormControl, FormField, Input, TextArea } from 'ui' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' -export const SnowflakeFields = ({ form }: { form: UseFormReturn<DestinationPanelSchemaType> }) => { +export const SnowflakeFields = ({ + form, + editMode, +}: { + form: UseFormReturn<DestinationPanelSchemaType> + editMode: boolean +}) => { const [showPrivateKeyPassphrase, setShowPrivateKeyPassphrase] = useState(false) return ( @@ -118,14 +125,22 @@ export const SnowflakeFields = ({ form }: { form: UseFormReturn<DestinationPanel <FormItemLayout layout="horizontal" label="Private key" - description="RSA private key PEM contents in PKCS#8 or PKCS#1 format" + description={ + editMode + ? 'Stored private key is hidden. Enter a new private key to replace it.' + : 'RSA private key PEM contents in PKCS#8 or PKCS#1 format' + } > <FormControl> <TextArea {...field} rows={8} maxLength={10000} - placeholder={'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'} + placeholder={ + editMode + ? STORED_SECRET_PLACEHOLDER + : '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----' + } value={field.value ?? ''} className="font-mono text-xs" /> @@ -141,13 +156,17 @@ export const SnowflakeFields = ({ form }: { form: UseFormReturn<DestinationPanel <FormItemLayout layout="horizontal" label="Private key passphrase" - description="Optional passphrase for encrypted private keys" + description={ + editMode + ? 'Stored passphrase setting is hidden. Enter a new passphrase to replace it.' + : 'Optional passphrase for encrypted private keys' + } > <FormControl> <PasswordInput value={field.value ?? ''} type={showPrivateKeyPassphrase ? 'text' : 'password'} - placeholder="Optional" + placeholder={editMode ? STORED_SECRET_PLACEHOLDER : 'Optional'} onChange={(event) => field.onChange(event.target.value)} actions={ <div className="flex items-center justify-center"> diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts index 5f25845435d..fa270daba84 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.ts @@ -31,6 +31,11 @@ const SNOWFLAKE_REQUIRED_FIELDS: { path: SnowflakeFieldPath; message: string }[] ] export const getSnowflakeValidationIssues = ( - data: Pick<DestinationPanelSchemaType, SnowflakeFieldPath> + data: Pick<DestinationPanelSchemaType, SnowflakeFieldPath>, + options: { secretsOptional?: boolean } = {} ): SnowflakeValidationIssue[] => - SNOWFLAKE_REQUIRED_FIELDS.filter(({ path }) => !data[path]?.trim().length) + SNOWFLAKE_REQUIRED_FIELDS.filter(({ path }) => { + if (options.secretsOptional && path === 'snowflakePrivateKey') return false + + return !data[path]?.trim().length + }) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx index 868cbfb6678..2b269102958 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx @@ -1,5 +1,5 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Badge, Card } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Markdown } from '@/components/interfaces/Markdown' import type { ValidationFailure } from '@/data/replication/validate-destination-mutation' @@ -36,7 +36,7 @@ export const ValidationFailuresSection = ({ <p className="text-sm text-foreground-light mb-2!"> {hasCriticalFailures ? `Please fix all required issues below${hasWarnings ? ' and review the others' : ''} before continuing.` - : 'The following issues were identified, although you may still proceed to create the destination.'} + : 'The following issues were identified, although you may still create the pipeline and start replication to the destination.'} </p> <Card> <Accordion type="multiple"> diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx index 818a41ce9c8..51cf36f9663 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx @@ -32,13 +32,13 @@ export const ValidationWarningsDialog = ({ <AlertDialogHeader> <AlertDialogTitle> {hasWarnings - ? `Create destination with ${warningCount} ${warningCount === 1 ? 'warning' : 'warnings'}?` - : 'Confirm to create destination'} + ? `Create and start pipeline with ${warningCount} ${warningCount === 1 ? 'warning' : 'warnings'}?` + : 'Create and start pipeline?'} </AlertDialogTitle> <AlertDialogDescription> {hasWarnings ? 'Replication can start, but the warnings listed above may affect how some changes are applied downstream. Review them before proceeding.' - : 'No validation issues were found. Confirm to create the destination and start replication.'} + : 'No validation issues were found. Confirm to create the pipeline and start replication to the destination.'} </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> @@ -49,7 +49,7 @@ export const ValidationWarningsDialog = ({ loading={isLoading} onClick={onConfirm} > - {hasWarnings ? 'Proceed to create' : 'Create destination'} + {hasWarnings ? 'Create and start pipeline anyway' : 'Create and start pipeline'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index ec836107817..e1e376e0350 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -157,21 +157,30 @@ export const DestinationForm = ({ } if (selectedType === 'BigQuery') { - getBigQueryValidationIssues(data).forEach(({ path, message }) => { - addRequiredFieldError(path, message) - }) + getBigQueryValidationIssues(data, { secretsOptional: editMode }).forEach( + ({ path, message }) => { + addRequiredFieldError(path, message) + } + ) } else if (selectedType === 'Analytics Bucket') { - getAnalyticsBucketValidationIssues(data).forEach(({ path, message }) => { + getAnalyticsBucketValidationIssues(data, { + secretsOptional: editMode, + storedS3AccessKeyId: editMode ? defaultValues.s3AccessKeyId : undefined, + }).forEach(({ path, message }) => { addRequiredFieldError(path, message) }) } else if (selectedType === 'DuckLake') { - getDucklakeValidationIssues(data).forEach(({ path, message }) => { - addRequiredFieldError(path, message) - }) + getDucklakeValidationIssues(data, { secretsOptional: editMode }).forEach( + ({ path, message }) => { + addRequiredFieldError(path, message) + } + ) } else if (selectedType === 'Snowflake') { - getSnowflakeValidationIssues(data).forEach(({ path, message }) => { - addRequiredFieldError(path, message) - }) + getSnowflakeValidationIssues(data, { secretsOptional: editMode }).forEach( + ({ path, message }) => { + addRequiredFieldError(path, message) + } + ) } }) ), @@ -193,13 +202,15 @@ export const DestinationForm = ({ const getSubmitButtonText = () => { if (editMode) { - return existingDestination?.enabled ? 'Apply and restart' : 'Apply and start' + return existingDestination?.enabled + ? 'Apply and restart pipeline' + : 'Apply and start pipeline' } else { if (hasRunValidation && validationWarnings.length > 0 && !hasValidationFailures) { - return 'Create and start anyway' + return 'Create and start pipeline anyway' } - return 'Create and start' + return 'Create and start pipeline' } } @@ -317,17 +328,18 @@ export const DestinationForm = ({ <DialogSectionSeparator /> {selectedType === 'BigQuery' && etlEnableBigQuery ? ( - <BigQueryFields form={form} /> + <BigQueryFields form={form} editMode={editMode} /> ) : selectedType === 'Analytics Bucket' && etlEnableIceberg ? ( <AnalyticsBucketFields form={form} + editMode={editMode} setIsFormInteracting={setIsFormInteracting} onSelectNewBucket={() => setNewBucketSheetVisible(true)} /> ) : selectedType === 'DuckLake' && etlEnableDucklake ? ( <DuckLakeFields form={form} editMode={editMode} /> ) : selectedType === 'Snowflake' && etlEnableSnowflake ? ( - <SnowflakeFields form={form} /> + <SnowflakeFields form={form} editMode={editMode} /> ) : null} <DialogSectionSeparator /> @@ -365,7 +377,11 @@ export const DestinationForm = ({ <p className="text-foreground-light text-sm"> {isValidating ? 'Validating destination configuration...' - : `${editMode ? 'Updating' : 'Creating'} destination...`} + : editMode + ? existingDestination?.enabled + ? 'Updating destination and restarting pipeline...' + : 'Updating destination and starting pipeline...' + : 'Creating pipeline...'} </p> </motion.div> ) : ( diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts index e12b66aedcd..4455b0342bb 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts @@ -243,16 +243,16 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination PipelineStatusRequestStatus.RestartRequested, snapshot ) - await restartPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) toast.success('Settings applied. Restarting the pipeline...') + restartPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) } else { setRequestStatus( existingDestination.pipelineId, PipelineStatusRequestStatus.StartRequested, snapshot ) - await startPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) toast.success('Settings applied. Starting the pipeline...') + startPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) } onClose() } else { @@ -268,13 +268,17 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination ) // Set request status only right before starting, then fire and close setRequestStatus(pipelineId, PipelineStatusRequestStatus.StartRequested, undefined) - await startPipeline({ projectRef, pipelineId }) - toast.success('Destination created. Starting the pipeline...') + toast.success('Pipeline created. Starting the pipeline...') + startPipeline({ projectRef, pipelineId }) onClose() } } catch (error) { - const action = editMode ? 'apply and run' : 'create and start' - toast.error(`Failed to ${action} destination: ${(error as ResponseError).message}`) + const action = editMode + ? existingDestination?.enabled + ? 'apply changes and restart pipeline' + : 'apply changes and start pipeline' + : 'create and start pipeline' + toast.error(`Failed to ${action}: ${(error as ResponseError).message}`) } } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index 6d17a68bc1b..de5192ff12c 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -15,7 +15,7 @@ import { SheetTitle, } from 'ui' -import { EnableReplicationCallout } from '../EnableReplicationCallout' +import { EnablePipelinesCallout } from '../EnablePipelinesCallout' import { PipelineStatusName } from '../Replication.constants' import { useDestinationInformation } from '../useDestinationInformation' import { useIsETLPrivateAlpha } from '../useIsETLPrivateAlpha' @@ -100,11 +100,11 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane <SheetContent size="lg" showClose={false}> <div className="flex flex-col h-full" tabIndex={-1}> <SheetHeader> - <SheetTitle>{editMode ? 'Edit destination' : 'Create a new destination'}</SheetTitle> + <SheetTitle>{editMode ? 'Edit destination' : 'Add destination'}</SheetTitle> <SheetDescription> {editMode - ? 'Update the configuration for this destination' - : 'A destination can be a read replica or an external destination that receives your database changes in real time.'} + ? 'Update the configuration for this destination.' + : 'A destination can be a read replica or an external destination that receives replicated data in near real time.'} </SheetDescription> </SheetHeader> @@ -118,11 +118,11 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane <SheetSection> <div className={cn('border rounded-md p-6 flex flex-col gap-y-4')}> <div className="flex flex-col gap-y-1"> - <h4>Replicate data to external destinations in real time</h4> + <h4>Request Pipelines access</h4> <p className="text-sm text-foreground-light"> - External destinations are in <span className="text-foreground">alpha</span>{' '} - and are being rolled out gradually. Request access below to join the waitlist. - Read replicas are available now. + Pipelines is in <span className="text-foreground">alpha</span> and being + rolled out gradually. Request access below to join the waitlist. Read replicas + are available now. </p> </div> <div className="flex gap-x-2"> @@ -132,20 +132,20 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane iconRight={<ArrowUpRight size={16} strokeWidth={1.5} />} > <Link - href="https://forms.supabase.com/pg_replicate" target="_blank" rel="noreferrer" + href="https://forms.supabase.com/pg_replicate" > Request alpha access </Link> </Button> - <DocsButton href={`${DOCS_URL}/guides/database/replication#replication`} /> + <DocsButton href={`${DOCS_URL}/guides/database/replication#pipelines`} /> </div> </div> </SheetSection> ) : replicationNotEnabled ? ( <SheetSection> - <EnableReplicationCallout + <EnablePipelinesCallout className="p-6!" type={destinationType} hasAccess={hasETLReplicationAccess} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx index 358bda15eac..f5d2d442785 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx @@ -64,7 +64,7 @@ describe('DestinationTypeSelection', () => { expect(await screen.findByText('Select a destination type')).toBeInTheDocument() }) - test('renders the Within Supabase group with Read Replica when dropdown is opened', async () => { + test('renders Read Replica in the Other group when dropdown is opened', async () => { mockBigQueryEnabled.mockReturnValue(false) mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) @@ -75,11 +75,11 @@ describe('DestinationTypeSelection', () => { fireEvent.click(await screen.findByRole('combobox')) - expect(await screen.findByText('Within Supabase')).toBeInTheDocument() + expect(await screen.findByText('Other')).toBeInTheDocument() expect(screen.getByText('Read Replica')).toBeInTheDocument() }) - test('renders the Outside Supabase group with BigQuery when the flag is enabled', async () => { + test('renders the Pipelines group with BigQuery when the flag is enabled', async () => { mockBigQueryEnabled.mockReturnValue(true) mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) @@ -90,7 +90,7 @@ describe('DestinationTypeSelection', () => { fireEvent.click(await screen.findByRole('combobox')) - expect(await screen.findByText('Outside Supabase')).toBeInTheDocument() + expect(await screen.findByText('Pipelines')).toBeInTheDocument() expect(screen.getByText('BigQuery')).toBeInTheDocument() }) @@ -105,11 +105,12 @@ describe('DestinationTypeSelection', () => { fireEvent.click(await screen.findByRole('combobox')) - await screen.findByText('Within Supabase') + expect(await screen.findByText('Other')).toBeInTheDocument() + expect(screen.getByText('Read Replica')).toBeInTheDocument() expect(screen.queryByText('BigQuery')).not.toBeInTheDocument() expect(screen.queryByText('DuckLake')).not.toBeInTheDocument() expect(screen.queryByText('Analytics Bucket')).not.toBeInTheDocument() - expect(screen.queryByText('Outside Supabase')).not.toBeInTheDocument() + expect(screen.queryByText('Pipelines')).not.toBeInTheDocument() }) test('shows alpha warning when an alpha destination type is selected', async () => { diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx index 2d936058cf0..c528ff2e08e 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -8,6 +8,7 @@ import { SelectGroup, SelectItem, SelectLabel, + SelectSeparator, SelectTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -74,7 +75,7 @@ export const DestinationTypeSelection = () => { const groups: DestinationTypeGroup[] = [ { - label: 'Within Supabase', + label: 'Other', options: [ { value: 'Read Replica', @@ -85,6 +86,11 @@ export const DestinationTypeSelection = () => { isAlpha: false, enabled: isOptionVisible('Read Replica', infrastructureReadReplicas), }, + ], + }, + { + label: 'Pipelines', + options: [ { value: 'Analytics Bucket', label: 'Analytics Bucket', @@ -93,11 +99,6 @@ export const DestinationTypeSelection = () => { isAlpha: true, enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg), }, - ], - }, - { - label: 'Outside Supabase', - options: [ { value: 'BigQuery', label: 'BigQuery', @@ -145,7 +146,8 @@ export const DestinationTypeSelection = () => { description={ selectedOption?.isAlpha && ( <span className="block text-sm text-foreground-light mb-1"> - This destination type is in alpha and may change while we iterate.{' '} + This destination type is in alpha and may be unstable or introduce breaking changes + while we iterate based on customer feedback.{' '} <InlineLink href="https://github.com/orgs/supabase/discussions/39416"> Leave feedback </InlineLink> @@ -172,8 +174,9 @@ export const DestinationTypeSelection = () => { )} </SelectTrigger> <SelectContent align="end"> - {visibleGroups.map((group) => ( + {visibleGroups.map((group, index) => ( <SelectGroup key={group.label}> + {index > 0 && <SelectSeparator />} <SelectLabel>{group.label}</SelectLabel> {group.options.map((option) => ( <SelectItem key={option.value} value={option.value} className="py-2"> diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx index baa771a432e..cfd10104d3d 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useCheckEligibilityDeployReplica } from './useCheckEligibilityDeployReplica' import { SupportLink } from '@/components/interfaces/Support/SupportLink' diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index fe72e424989..a07c74aaf09 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -169,7 +169,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { ) : ( <div> <p> - {type} (ID: {pipeline?.id}) + {type} (Pipeline ID: {pipeline?.id}) </p> <p className="text-foreground-lighter">{destinationName}</p> </div> @@ -228,7 +228,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { )} <Button asChild variant="default" className="relative"> <Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}> - View replication + View pipeline </Link> </Button> <RowMenu diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 462cb4c7125..b137bb77234 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -19,14 +19,15 @@ import { TableHeader, TableRow, } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { REPLICA_STATUS } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' import { DestinationPanel } from './DestinationPanel/DestinationPanel' import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' -import { DisableExternalReplicationDialog } from './DisableExternalReplicationDialog' +import { DisablePipelinesDialog } from './DisablePipelinesDialog' import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow' import { useIsETLBigQueryPrivateAlpha, @@ -75,8 +76,7 @@ export const Destinations = () => { const searchInputRef = useRef<HTMLInputElement>(null) const [filterString, setFilterString] = useState<string>('') const [statusRefetchInterval, setStatusRefetchInterval] = useState<number | false>(5000) - const [showDisableExternalReplicationDialog, setShowDisableExternalReplicationDialog] = - useState(false) + const [showDisablePipelinesDialog, setShowDisablePipelinesDialog] = useState(false) const [_, setDestinationType] = useQueryState( 'destinationType', @@ -99,7 +99,17 @@ export const Destinations = () => { isError: isDatabasesError, isSuccess: isDatabasesSuccess, } = useReadReplicasQuery({ projectRef }, { refetchInterval: statusRefetchInterval }) - const readReplicas = databases.filter((x) => x.identifier !== projectRef) + // Memoise so the array reference is stable across renders. Without this + // the polling useEffect below has an unstable dep, runs every render, and + // its `setStatusRefetchInterval(false)` churn keeps the parent re-rendering + // — which trips a latent ref-instability bug in @radix-ui/react-slot + // (`composeRefs` is called per render instead of `useComposedRefs`) and + // tanks the page with "Maximum update depth exceeded" via the Tooltip + // trigger refs. + const readReplicas = useMemo( + () => databases.filter((x) => x.identifier !== projectRef), + [databases, projectRef] + ) const hasReplicas = isDatabasesSuccess && readReplicas.length > 0 const filteredReplicas = filterString.length === 0 @@ -136,7 +146,7 @@ export const Destinations = () => { () => sourcesData?.sources.find((source) => source.name === projectRef), [projectRef, sourcesData?.sources] ) - const canDisableExternalReplication = + const canDisablePipelines = isSourcesSuccess && isDestinationsSuccess && isPipelinesSuccess && @@ -249,14 +259,14 @@ export const Destinations = () => { </Button> </Shortcut> <DocsButton href={`${DOCS_URL}/guides/database/replication`} /> - {canDisableExternalReplication && ( + {canDisablePipelines && ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="default" icon={<MoreVertical />} className="w-7" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-52"> - <DropdownMenuItem onClick={() => setShowDisableExternalReplicationDialog(true)}> - Disable external replication + <DropdownMenuItem onClick={() => setShowDisablePipelinesDialog(true)}> + Disable Pipelines </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -346,8 +356,8 @@ export const Destinations = () => { > <h4>Replication keeps your data in sync across systems</h4> <p className="text-foreground-light text-sm text-balance text-center mt-1"> - Deploy read replicas for lower latency and better resource management, or capture - database changes to external destinations for real-time data pipelines. + Deploy Read Replicas for lower latency and workload isolation, or add a Pipelines + destination for analytics workloads. </p> <Button icon={<Plus />} @@ -364,9 +374,9 @@ export const Destinations = () => { <DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} /> - <DisableExternalReplicationDialog - open={showDisableExternalReplicationDialog} - setOpen={setShowDisableExternalReplicationDialog} + <DisablePipelinesDialog + open={showDisablePipelinesDialog} + setOpen={setShowDisablePipelinesDialog} /> </> ) diff --git a/apps/studio/components/interfaces/Database/Replication/DisableExternalReplicationDialog.tsx b/apps/studio/components/interfaces/Database/Replication/DisablePipelinesDialog.tsx similarity index 83% rename from apps/studio/components/interfaces/Database/Replication/DisableExternalReplicationDialog.tsx rename to apps/studio/components/interfaces/Database/Replication/DisablePipelinesDialog.tsx index 90d235cf975..d9bab23243e 100644 --- a/apps/studio/components/interfaces/Database/Replication/DisableExternalReplicationDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DisablePipelinesDialog.tsx @@ -16,22 +16,19 @@ import { Admonition } from 'ui-patterns/admonition' import { useDeleteReplicationTenantMutation } from '@/data/replication/delete-tenant-mutation' -interface DisableExternalReplicationDialogProps { +interface DisablePipelinesDialogProps { open: boolean setOpen: (value: boolean) => void } -export const DisableExternalReplicationDialog = ({ - open, - setOpen, -}: DisableExternalReplicationDialogProps) => { +export const DisablePipelinesDialog = ({ open, setOpen }: DisablePipelinesDialogProps) => { const { ref: projectRef } = useParams() const [error, setError] = useState<string | null>(null) const { mutateAsync: deleteReplicationTenant, isPending: isSubmitting } = useDeleteReplicationTenantMutation({ onSuccess: () => { - toast.success('External replication has been disabled') + toast.success('Pipelines has been disabled') setOpen(false) }, onError: () => {}, @@ -53,12 +50,12 @@ export const DisableExternalReplicationDialog = ({ <AlertDialog open={open} onOpenChange={(open) => !isSubmitting && setOpen(open)}> <AlertDialogContent size="small"> <AlertDialogHeader> - <AlertDialogTitle>Disable external replication</AlertDialogTitle> + <AlertDialogTitle>Disable Pipelines</AlertDialogTitle> <AlertDialogDescription className="space-y-2 text-sm"> <p> This will remove the <code className="text-code-inline">etl</code> schema and all - connected resources from your database. Any active pipelines sending changes to - external destinations will stop. + connected resources from your database. Any active replication pipelines sending + changes to external destinations will stop. </p> <p>Read replicas are not affected.</p> </AlertDialogDescription> @@ -67,7 +64,7 @@ export const DisableExternalReplicationDialog = ({ <AlertDialogBody> <Admonition type="destructive" - title="Unable to disable external replication" + title="Unable to disable Pipelines" description={error} /> </AlertDialogBody> diff --git a/apps/studio/components/interfaces/Database/Replication/EnableReplicationCallout.tsx b/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx similarity index 67% rename from apps/studio/components/interfaces/Database/Replication/EnableReplicationCallout.tsx rename to apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx index 8c088ab212b..602bda15097 100644 --- a/apps/studio/components/interfaces/Database/Replication/EnableReplicationCallout.tsx +++ b/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx @@ -13,7 +13,7 @@ import { DialogTitle, DialogTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DocsButton } from '@/components/ui/DocsButton' @@ -21,22 +21,22 @@ import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useCreateTenantSourceMutation } from '@/data/replication/create-tenant-source-mutation' import { DOCS_URL } from '@/lib/constants' -const EnableReplicationModal = () => { +const EnablePipelinesModal = () => { const { ref: projectRef } = useParams() const [open, setOpen] = useState(false) const { mutate: createTenantSource, isPending: creatingTenantSource } = useCreateTenantSourceMutation({ onSuccess: () => { - toast.success('External replication has been successfully enabled!') + toast.success('Pipelines has been successfully enabled!') setOpen(false) }, onError: (error) => { - toast.error(`Failed to enable external replication: ${error.message}`) + toast.error(`Failed to enable Pipelines: ${error.message}`) }, }) - const onEnableReplication = async () => { + const onEnablePipelines = async () => { if (!projectRef) return console.error('Project ref is required') createTenantSource({ projectRef }) } @@ -45,27 +45,27 @@ const EnableReplicationModal = () => { <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button variant="primary" className="w-min"> - Enable external replication + Enable Pipelines </Button> </DialogTrigger> <DialogContent> <DialogHeader> - <DialogTitle>Enable external replication</DialogTitle> + <DialogTitle>Enable Pipelines</DialogTitle> </DialogHeader> <DialogSectionSeparator /> <DialogSection className="flex flex-col gap-y-2 p-0!"> <Admonition type="warning" className="rounded-none border-0" - title="Replication is currently in alpha" + title="Pipelines is currently in alpha" > <p className="text-sm leading-normal!"> - External replication streams database changes to destinations outside your Supabase - project. The feature is in active development and may change as we gather feedback. + Alpha features can be unstable and may introduce breaking changes while we evaluate + the product direction, refine the feature set, and incorporate customer feedback. </p> <p className="text-sm leading-normal!"> - Pricing is not finalized. You can enable external replication now; we'll announce - pricing later and notify you before any charges apply. + Pricing is not finalized. You can enable Pipelines now; we'll announce pricing later + and notify you before any charges apply. </p> </Admonition> </DialogSection> @@ -73,8 +73,8 @@ const EnableReplicationModal = () => { <Button variant="default" disabled={creatingTenantSource} onClick={() => setOpen(false)}> Cancel </Button> - <Button variant="primary" loading={creatingTenantSource} onClick={onEnableReplication}> - Enable external replication + <Button variant="primary" loading={creatingTenantSource} onClick={onEnablePipelines}> + Enable Pipelines </Button> </DialogFooter> </DialogContent> @@ -82,7 +82,7 @@ const EnableReplicationModal = () => { ) } -export const EnableReplicationCallout = ({ +export const EnablePipelinesCallout = ({ type, className, hasAccess, @@ -94,19 +94,21 @@ export const EnableReplicationCallout = ({ return ( <div className={cn('border rounded-md p-4 md:p-12 flex flex-col gap-y-4', className)}> <div className="flex flex-col gap-y-1"> - <h4>Replicate data to external destinations in real time</h4> + <h4>Enable Pipelines</h4> <p className="text-sm text-foreground-light"> - {hasAccess ? 'Enable external replication' : 'Upgrade to the Pro plan'} to start - replicating your database changes to {type ?? 'data warehouses and analytics platforms'}. + Pipelines creates managed replication pipelines that stream database changes to + destination systems.{' '} + {hasAccess ? 'Enable Pipelines for your project' : 'Upgrade to the Pro plan'} to stream + database changes to {type ?? 'data warehouses and analytics platforms'}. </p> </div> <div className="flex gap-x-2"> {hasAccess ? ( - <EnableReplicationModal /> + <EnablePipelinesModal /> ) : ( <UpgradePlanButton source="replication" featureProposition="use replication" /> )} - <DocsButton href={`${DOCS_URL}/guides/database/replication#replication`} /> + <DocsButton href={`${DOCS_URL}/guides/database/replication#pipelines`} /> </div> </div> ) diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx b/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx index c40af9169b7..75f200a5a28 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx @@ -12,10 +12,10 @@ import { ChartLine, ChartLoadingState, ChartMetric, - GenericSkeletonLoader, -} from 'ui-patterns' +} from 'ui-patterns/Chart' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants' import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx b/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx index 6838cdaa3b5..b278717dda8 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx @@ -18,7 +18,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getIsInTransition, getStatusLabel } from './ReadReplicas.utils' import { DropReplicaConfirmationModal } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DropReplicaConfirmationModal' @@ -118,7 +118,7 @@ export const ReadReplicaRow = ({ replica, onUpdateReplica }: ReadReplicaRow) => disabled={status === 'GOING_DOWN'} > <Link href={`/project/${ref}/database/replication/replica/${replica.identifier}`}> - View replication + View replica </Link> </Button> <DropdownMenu> diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx index 84eebdfbc91..4f35e64e14e 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx @@ -56,7 +56,7 @@ const getEdgeVisual = ({ isComingUp, isReplicating, isFailed }: ReplicationState if (isComingUp) { return { Icon: Loader2, - color: 'hsl(var(--foreground-light))', + color: 'var(--foreground-light)', opacity: 1, dashArray: '5', shouldAnimate: true, @@ -74,7 +74,7 @@ const getEdgeVisual = ({ isComingUp, isReplicating, isFailed }: ReplicationState } return { Icon: Square, - color: 'hsl(var(--foreground-lighter))', + color: 'var(--foreground-lighter)', opacity: 0.5, dashArray: '5 5', shouldAnimate: false, diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx index 81996d4cd07..7fd2336cc6f 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx @@ -30,8 +30,8 @@ import { TableHeader, TableRow, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { BatchRestartDialog } from '../BatchRestartDialog' import { ErrorDetailsDialog } from '../ErrorDetailsDialog' diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx index 68ebf1d39e2..9ab2ebcd4c2 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx @@ -12,8 +12,8 @@ export const getStatusConfig = (state: TableState['state']) => { case 'queued': return { badge: <Badge variant="warning">Queued</Badge>, - description: 'Table is waiting for ETL to pick it up for replication.', - tooltip: 'Table is waiting for ETL to pick it up for replication.', + description: 'Table is waiting for the pipeline to pick it up for replication.', + tooltip: 'Table is waiting for the pipeline to pick it up for replication.', color: 'text-warning', } case 'copying_table': diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx index e1d8f0c24d7..70a1397f20a 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx @@ -91,7 +91,7 @@ export const SlotStatusLegend = () => { </ul> <div className="border-t border-overlay px-4 py-2.5"> <InlineLink - href={`${DOCS_URL}/guides/database/replication/external-replication-monitoring`} + href={`${DOCS_URL}/guides/database/replication/pipelines-monitoring`} className="text-xs text-foreground-light" > Learn more about monitoring replication diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx index 51bbe46529b..17f61d935c7 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx @@ -47,7 +47,7 @@ export const TableReplicationRow = ({ className="px-1.5" icon={<ExternalLink />} tooltip={{ - content: { side: 'bottom', text: 'Open in Table Editor' }, + content: { side: 'bottom', text: 'Table Editor' }, }} > <Link diff --git a/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts b/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts index 71643eda753..3f9c695be9f 100644 --- a/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts +++ b/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts @@ -3,9 +3,9 @@ import { useFlag } from 'common' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' /** - * Organization level opt in for ETL private alpha, there's 2 flags we're using which controls - * the individual destination types. Access to the ETL UI (`useIsETLPrivateAlpha`) will just - * check if the org has access to at least one of the destination types + * Organization-level access for Pipelines alpha destinations. The flag names still use + * the original ETL identifiers, but the UI checks whether the org has access to at least one + * destination type. */ const useIsCurrentOrgInFlagList = (flag: string) => { const flagValue = useFlag(flag) @@ -16,7 +16,7 @@ const useIsCurrentOrgInFlagList = (flag: string) => { ? (flagValue as string).split(',').map((x: string) => x.trim()) : [] - // [Joshen] Override for to enable for all organizations by setting the flag value as `all` + // [Joshen] Override to enable for all organizations by setting the flag value as `all` if (allowedOrgSlugs.includes('all')) return true // [Joshen] Otherwise fallback to checking against org slug diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/PreviousRestoreItem.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/PreviousRestoreItem.tsx index d293925eee4..f804e048ebc 100644 --- a/apps/studio/components/interfaces/Database/RestoreToNewProject/PreviousRestoreItem.tsx +++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/PreviousRestoreItem.tsx @@ -1,6 +1,6 @@ import { ChevronRightIcon } from 'lucide-react' import Link from 'next/link' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { StatusBadge } from './StatusBadge' import { CloneStatus } from '@/data/projects/clone-status-query' diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx index d82a3cc58c2..40da5f66a2a 100644 --- a/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx +++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx @@ -14,9 +14,9 @@ import { CreateNewProjectDialog } from '@/components/interfaces/Database/Backups import { projectSpecToMonthlyPrice } from '@/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils' import { DiskType } from '@/components/interfaces/DiskManagement/ui/DiskManagement.constants' import { Markdown } from '@/components/interfaces/Markdown' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import Panel from '@/components/ui/Panel' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query' @@ -157,7 +157,7 @@ export const RestoreToNewProject = () => { > <Markdown className="max-w-full" - content={`Restore to new project is only available for Postgres 15 and above. + content={`Restore to new project is only available for Postgres 15 and above. Go to [infrastructure settings](/project/${project?.ref}/settings/infrastructure) to upgrade your database version. `} diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 85053d5f71a..a5bc857b406 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -53,9 +53,9 @@ import { } from './Schemas.utils' import { TableNode } from './SchemaTableNode' import { useExportSchemaToImage } from './useExportSchemaToImage' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useSchemasQuery } from '@/data/database/schemas-query' import { useInfiniteTablesQuery } from '@/data/tables/tables-query' diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx index 1d3b6457b6d..88554e4a406 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx @@ -249,15 +249,7 @@ const TableNodeComponent = ({ )} > {column.isPrimary && ( - <Key - size={8} - strokeWidth={1} - className={cn( - // 'sb-grid-column-header__inner__primary-key' - 'shrink-0', - 'text-light' - )} - /> + <Key size={8} strokeWidth={1} className={cn('shrink-0', 'text-light')} /> )} {column.isNullable && ( <DiamondIcon size={8} strokeWidth={1} className="shrink-0 text-light" /> diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 89d68956e09..37c70b887a4 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -48,7 +48,7 @@ import { } from './ColumnList.utils' import { ConstraintToken } from './ConstraintToken' import { displayColumnType } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' import { NoSearchResults } from '@/components/ui/NoSearchResults' @@ -162,6 +162,7 @@ export const ColumnList = ({ {!isSchemaLocked && isTableEntity && ( <ButtonTooltip icon={<Plus />} + variant="default" disabled={!canUpdateColumns} onClick={() => onAddColumn()} tooltip={{ diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 796a72ec4d2..b25c9e57e5a 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -37,11 +37,11 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import { formatAllEntities } from './Tables.utils' import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' @@ -265,12 +265,18 @@ export const TableList = ({ </Shortcut> <Popover> <PopoverTrigger asChild> - <Button - size="tiny" - variant={visibleTypes.length !== 5 ? 'default' : 'dashed'} - className="px-1" - icon={<Filter />} - /> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="tiny" + variant={visibleTypes.length !== 5 ? 'default' : 'dashed'} + className="px-1" + icon={<Filter />} + aria-label="Filter" + /> + </TooltipTrigger> + <TooltipContent>Filter</TooltipContent> + </Tooltip> </PopoverTrigger> <PopoverContent className="p-0 w-56" side="bottom" align="center"> <div className="px-3 pt-3 pb-2 flex flex-col gap-y-2"> diff --git a/apps/studio/components/interfaces/Database/Triggers/ChooseFunctionForm.tsx b/apps/studio/components/interfaces/Database/Triggers/ChooseFunctionForm.tsx index 1731d0b3ae5..b14d67361bc 100644 --- a/apps/studio/components/interfaces/Database/Triggers/ChooseFunctionForm.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/ChooseFunctionForm.tsx @@ -6,8 +6,8 @@ import { useRouter } from 'next/router' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, SidePanel } from 'ui' import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import InformationBox from '@/components/ui/InformationBox' -import SqlEditor from '@/components/ui/SqlEditor' import { useDatabaseFunctionsQuery, type DatabaseFunction, @@ -163,7 +163,12 @@ const Function = ({ id, completeStatement, name, onClick }: FunctionProps) => { </div> <AccordionContent className="[&>div]:pb-0" onClick={(e) => e.stopPropagation()}> <div className="mt-4 h-64 border border-default"> - <SqlEditor defaultValue={completeStatement} readOnly={true} contextmenu={false} /> + <CodeEditor + isReadOnly + language="pgsql" + defaultValue={completeStatement} + options={{ contextmenu: false }} + /> </div> </AccordionContent> </AccordionItem> diff --git a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants.ts b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants.ts index b59bc48f72e..869388398cc 100644 --- a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants.ts +++ b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants.ts @@ -16,6 +16,7 @@ EXECUTE FUNCTION event_trigger_fn(); ` export const AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL = safeSql` +-- Event trigger: ensure_rls CREATE OR REPLACE FUNCTION rls_auto_enable() RETURNS EVENT_TRIGGER LANGUAGE plpgsql diff --git a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx index 9bfbc57e35a..32b7c968848 100644 --- a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx @@ -15,7 +15,7 @@ import { TableHeader, TableRow, } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { EventTriggerList } from './EventTriggerList' @@ -27,7 +27,7 @@ import { selectFilterSchema, } from '@/components/interfaces/Reports/v2/ReportsSelectFilter' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' import { Shortcut } from '@/components/ui/Shortcut' diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index a68be46a96a..e0a28f20185 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -14,6 +14,9 @@ import { DropdownMenuTrigger, TableCell, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { @@ -174,14 +177,19 @@ export const TriggerList = ({ editTrigger, duplicateTrigger, deleteTrigger }: Tr <div className="flex items-center justify-end"> {canUpdateTriggers ? ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="More options" - variant="default" - className="px-1" - icon={<MoreVertical />} - /> - </DropdownMenuTrigger> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + aria-label={`${x.function_name} actions`} + variant="default" + className="px-1" + icon={<MoreVertical />} + /> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">More options</TooltipContent> + </Tooltip> <DropdownMenuContent side="bottom" align="end" className="w-52"> <DropdownMenuItem className="space-x-2" diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index c47e1b89520..c27c968c327 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -15,7 +15,7 @@ import { TableHeader, TableRow, } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { CreateTriggerButtons } from './CreateTriggerButtons' @@ -29,9 +29,9 @@ import { selectFilterSchema, } from '@/components/interfaces/Reports/v2/ReportsSelectFilter' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useDatabaseTriggerDeleteMutation } from '@/data/database-triggers/database-trigger-delete-mutation' diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index fcd531e1c8c..2f7a79cea00 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -17,7 +17,7 @@ import { Form, Separator, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge' import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema' @@ -41,7 +41,6 @@ import { PLAN_DETAILS, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3, } from './ui/DiskManagement.constants' -import { NoticeBar } from './ui/NoticeBar' import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' import { MAX_WIDTH_CLASSES, @@ -396,18 +395,22 @@ export function DiskManagementForm() { title="Your project will now automatically restart." description="Your project will be unavailable for up to 2 mins." /> - <NoticeBar - type="default" - visible={isProjectRequestingDiskChanges} - title="Disk configuration changes have been requested" - description="The requested changes will be applied to your disk shortly" - /> - <NoticeBar - type="default" - visible={isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions} - title="You do not have permission to update disk configuration" - description="Please contact your organization administrator to update your disk configuration" - /> + {isProjectRequestingDiskChanges && ( + <Admonition + type="default" + layout="horizontal" + title="Disk configuration changes have been requested" + description="The requested changes will be applied to your disk shortly" + /> + )} + {isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions && ( + <Admonition + type="default" + layout="horizontal" + title="You do not have permission to update disk configuration" + description="Please contact your organization administrator to update your disk configuration" + /> + )} </div> )} @@ -428,19 +431,20 @@ export function DiskManagementForm() { <SpendCapDisabledSection currentDiskSizeGb={defaultValues.totalSize} /> <div className="flex flex-col gap-y-4"> - <NoticeBar - type="default" - visible={isDiskNoticeVisible} - title="Disk configuration is only available for projects in the AWS cloud provider" - description={ - isAwsK8s - ? 'Configuring your disk for AWS (Revamped) projects is unavailable for now.' - : isBranch - ? 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.' - : 'The Fly Postgres offering is deprecated - please migrate your instance to the AWS cloud prov to configure your disk.' - } - /> - + {isDiskNoticeVisible && ( + <Admonition + type="default" + layout="horizontal" + title="Disk configuration is only available for projects in the AWS cloud provider" + description={ + isAwsK8s + ? 'Configuring your disk for AWS (Revamped) projects is unavailable for now.' + : isBranch + ? 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.' + : 'The Fly Postgres offering is deprecated - please migrate your instance to the AWS cloud prov to configure your disk.' + } + /> + )} {isAws && ( <> <div className="flex flex-col gap-y-3"> @@ -517,54 +521,54 @@ export function DiskManagementForm() { </div> <DialogSectionSeparator /> <div className="px-card flex flex-col gap-y-8"> - <NoticeBar - type="default" - visible={!!disableIopsThroughputConfig} - title="Adjusting disk configuration requires LARGE Compute size or above" - description={`Increase your compute size to adjust your disk's storage type, ${form.getValues('storageType') === 'gp3' ? 'IOPS, ' : ''} and throughput`} - actions={ - canUpdateDiskConfiguration ? ( - <Button - variant="default" - onClick={() => { - form.setValue('computeSize', 'ci_large') - }} - > - Change to LARGE Compute - </Button> - ) : ( - <RequestUpgradeToBillingOwners - addon="computeSize" - featureProposition="adjust disk configuration" - /> - ) - } - /> - <NoticeBar - type="default" - visible={ - isDiskTooSmallForCustomIops && - !disableIopsThroughputConfig && - !disableDiskInputs - } - title={`Increase disk size to adjust IOPS or throughput`} - description={`This disk is too small to update IOPS or throughput, since gp3 volumes are capped at 500 IOPS per GB with a 3,000 IOPS minimum. Resizing to ${suggestedDiskSizeForCustomIops} GB unlocks custom IOPS and throughput, and leaves headroom for further adjustments (disk config changes are locked for 4 hours after each resize).`} - actions={ - !disableDiskSizeInput ? ( - <Button - variant="default" - onClick={() => { - form.setValue('totalSize', suggestedDiskSizeForCustomIops, { - shouldDirty: true, - shouldValidate: true, - }) - }} - > - Increase to {suggestedDiskSizeForCustomIops} GB - </Button> - ) : undefined - } - /> + {!!disableIopsThroughputConfig && ( + <Admonition + type="default" + title="Adjusting disk configuration requires LARGE Compute size or above" + description={`Increase your compute size to adjust your disk's storage type, ${form.getValues('storageType') === 'gp3' ? 'IOPS, ' : ''} and throughput`} + actions={ + canUpdateDiskConfiguration ? ( + <Button + variant="default" + onClick={() => { + form.setValue('computeSize', 'ci_large') + }} + > + Change to LARGE Compute + </Button> + ) : ( + <RequestUpgradeToBillingOwners + addon="computeSize" + featureProposition="adjust disk configuration" + /> + ) + } + /> + )} + {isDiskTooSmallForCustomIops && + !disableIopsThroughputConfig && + !disableDiskInputs && ( + <Admonition + type="default" + title="Increase disk size to adjust IOPS or throughput" + description={`This disk is too small to update IOPS or throughput, since gp3 volumes are capped at 500 IOPS per GB with a 3,000 IOPS minimum. Resizing to ${suggestedDiskSizeForCustomIops} GB unlocks custom IOPS and throughput, and leaves headroom for further adjustments (disk config changes are locked for 4 hours after each resize).`} + actions={ + !disableDiskSizeInput ? ( + <Button + variant="default" + onClick={() => { + form.setValue('totalSize', suggestedDiskSizeForCustomIops, { + shouldDirty: true, + shouldValidate: true, + }) + }} + > + Increase to {suggestedDiskSizeForCustomIops} GB + </Button> + ) : undefined + } + /> + )} <StorageTypeField form={form} disableInput={disableIopsThroughputConfig || disableDiskInputs} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index f41ef295700..8257d48faed 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -1,14 +1,14 @@ import { useParams } from 'common' import Link from 'next/link' import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { PageSection, PageSectionContent, PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import { Admonition } from 'ui-patterns/admonition' +} from 'ui-patterns/PageSection' import { DocsButton } from '../../ui/DocsButton' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts index 954a1ac0a5d..edadd80c1b9 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { UseFormReturn } from 'react-hook-form' import { DiskStorageSchemaType } from '../DiskManagement.schema' +import { ComputeInstanceAddonVariantId } from '../DiskManagement.types' import { calculateComputeSizePrice, calculateDiskSizePrice, @@ -18,6 +19,13 @@ import { useSelectedProjectQuery, } from '@/hooks/misc/useSelectedProject' +const COMPUTE_SIZES_BELOW_LARGE: Array<ComputeInstanceAddonVariantId> = [ + 'ci_nano', + 'ci_micro', + 'ci_small', + 'ci_medium', +] + export function useDiskManagementReviewChanges( form: UseFormReturn<DiskStorageSchemaType>, numReplicas: number @@ -131,6 +139,16 @@ export function useDiskManagementReviewChanges( // Show cooldown warning whenever any disk attribute that enforces the 4-hour lock changes const anyDiskAttributeChange = hasIOPSChanges || hasStorageTypeChanges || hasTotalSizeChanges + // Show extended downtime warning when resizing to/from a size below large + const hasExtendedDowntimeRisk = + hasComputeChanges && + (COMPUTE_SIZES_BELOW_LARGE.includes( + (form.formState.defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId + ) || + COMPUTE_SIZES_BELOW_LARGE.includes( + form.getValues('computeSize') as ComputeInstanceAddonVariantId + )) + // Throughput is only a user-configurable, separately-billed attribute for GP3. For IO2 it is // derived from provisioned IOPS (0.256 MiB/s per IOPS) and isn't surfaced as its own value, so // the form clears it to 0 — rendering a misleading "→ 0 MB/s". Only show the row when the @@ -180,6 +198,7 @@ export function useDiskManagementReviewChanges( anyDiskAttributeChange, showThroughputRow, hasAnyBreakdownRows, + hasExtendedDowntimeRisk, // labels oldComputeLabel, newComputeLabel, diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx index 67839cc3310..bea2a4138b4 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ArrowRight } from 'lucide-react' +import { AlertTriangle, ArrowRight } from 'lucide-react' import { UseFormReturn } from 'react-hook-form' import { Alert, @@ -84,6 +84,7 @@ export const DiskManagementReviewAndSubmitDialog = ({ anyDiskAttributeChange, showThroughputRow, hasAnyBreakdownRows, + hasExtendedDowntimeRisk, oldComputeLabel, newComputeLabel, } = useDiskManagementReviewChanges(form, numReplicas) @@ -125,7 +126,7 @@ export const DiskManagementReviewAndSubmitDialog = ({ {(hasComputeChanges || anyBillableDiskChange) && ( <> <div className="relative flex border-b"> - <div className="flex-1 flex flex-col items-center gap-2 py-6 px-4 border-r bg-linear-to-t from-[hsl(var(--background-surface-100))] to-transparent"> + <div className="flex-1 flex flex-col items-center gap-2 py-6 px-4 border-r bg-linear-to-t from-[var(--background-surface-100)] to-transparent"> <span className="text-xs uppercase tracking-widest font-mono text-foreground-lighter"> Before </span> @@ -143,7 +144,7 @@ export const DiskManagementReviewAndSubmitDialog = ({ <ArrowRight size={16} className="text-brand-600 relative z-10" strokeWidth={2.5} /> </div> - <div className="flex-1 flex flex-col items-center gap-2 py-6 px-4 bg-linear-to-t from-[hsl(var(--background-surface-100))] to-transparent"> + <div className="flex-1 flex flex-col items-center gap-2 py-6 px-4 bg-linear-to-t from-[var(--background-surface-100)] to-transparent"> <span className="text-xs uppercase tracking-widest font-mono text-foreground-lighter"> After </span> @@ -267,6 +268,20 @@ export const DiskManagementReviewAndSubmitDialog = ({ </div> )} + {hasExtendedDowntimeRisk && ( + <> + <DialogSectionSeparator /> + <DialogSection> + <Alert variant="warning"> + <AlertTriangle /> + <AlertTitle> + Resizes may require more downtime than normal on this project. + </AlertTitle> + </Alert> + </DialogSection> + </> + )} + <DialogFooter className="px-5 py-4"> <Button block size="large" variant="default" onClick={() => setIsDialogOpen(false)}> Cancel diff --git a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx index db80564155a..08aecb8389d 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx @@ -8,7 +8,7 @@ import { InputGroupAddon, InputGroupText, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index a1ec081b884..43058206532 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -14,7 +14,8 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { ComputeBadge } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ComputeBadge } from 'ui-patterns/ComputeBadge' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' @@ -26,7 +27,6 @@ import { } from '../DiskManagement.utils' import { BillingChangeBadge } from '../ui/BillingChangeBadge' import FormMessage from '../ui/FormMessage' -import { NoticeBar } from '../ui/NoticeBar' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { DocsButton } from '@/components/ui/DocsButton' import { InlineLink } from '@/components/ui/InlineLink' @@ -167,14 +167,15 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { /> </div> - <NoticeBar - showIcon={false} - type="default" - className="mt-3 border-violet-900 bg-violet-200 [&_h5]:text-violet-1100" - visible={showUpgradeBadge && form.watch('computeSize') === 'ci_nano'} - title={'Upgrade to Micro Compute'} - description="This Project is already paying for Micro Compute. You can upgrade to Micro Compute at any time when convenient." - /> + {showUpgradeBadge && form.watch('computeSize') === 'ci_nano' && ( + <Admonition + showIcon={false} + type="default" + className="mt-3 border-violet-900 bg-violet-200 [&_h5]:text-violet-1100" + title="Upgrade to Micro Compute" + description="This Project is already paying for Micro Compute. You can upgrade to Micro Compute at any time when convenient." + /> + )} </> } > diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagementCoolDownSection.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskManagementCoolDownSection.tsx deleted file mode 100644 index 0f5c648cf4d..00000000000 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagementCoolDownSection.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' -import { useEffect, useState } from 'react' -import { DialogSection } from 'ui' - -import CountdownTimerRadial from '@/components/ui/CountdownTimer/CountdownTimerRadial' - -export const DiskMangementCoolDownSection = ({ visible }: { visible: boolean }) => { - const [progress, setProgress] = useState(100) - const [showCountdown, setShowCountdown] = useState(false) - const [isJumping, setIsJumping] = useState(false) - - useEffect(() => { - const startCountdown = () => { - setShowCountdown(true) - setProgress(100) - const interval = setInterval(() => { - setProgress((prevProgress) => { - if (prevProgress <= 0) { - clearInterval(interval) - setIsJumping(true) - setTimeout(() => { - setIsJumping(false) - setProgress(100) - startCountdown() // Restart the countdown - }, 300) // Duration of the jump animation - return 0 - } - return prevProgress - 1 - }) - }, 100) - - return () => clearInterval(interval) - } - - const initialDelay = setTimeout(startCountdown, 1000) - - return () => clearTimeout(initialDelay) - }, []) - - return ( - <AnimatePresence> - {visible && ( - <motion.div - initial={{ opacity: 1, height: 'auto' }} - exit={{ opacity: 0, height: 0 }} - transition={{ duration: 0.15 }} - className="w-full" - > - <DialogSection className="bg-surface-100 text-sm text-foreground-light flex items-center gap-4 relative w-full border rounded-md"> - <div className="w-12 h-12"> - <AnimatePresence> - {showCountdown && ( - <motion.div - key="countdown-timer" - initial={{ scale: 0.8, opacity: 0, y: -8 }} - animate={{ - scale: isJumping ? 1.2 : 1, - opacity: 1, - y: isJumping ? -8 : 0, - }} - exit={{ scale: 0.8, opacity: 0, y: -8 }} - transition={{ - duration: isJumping ? 0.3 : 0.8, - type: 'spring', - bounce: 0.6, - }} - > - <CountdownTimerRadial progress={progress} /> - </motion.div> - )} - </AnimatePresence> - </div> - <div className="flex flex-col gap-0 grow"> - <p className="text-sm text-foreground"> - For 4 hours you will not be able to change any disk attributes. - </p> - <p className="text-sm text-foreground-light"> - There is a cooldown period enforced for any disk attribute modifications - </p> - </div> - </DialogSection> - </motion.div> - )} - </AnimatePresence> - ) -} diff --git a/apps/studio/components/interfaces/DiskManagement/ui/FormMessage.tsx b/apps/studio/components/interfaces/DiskManagement/ui/FormMessage.tsx index 99c9ddf5d65..4352448e33c 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/FormMessage.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/FormMessage.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from 'framer-motion' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' interface FormMessageProps { message: string diff --git a/apps/studio/components/interfaces/DiskManagement/ui/NoticeBar.tsx b/apps/studio/components/interfaces/DiskManagement/ui/NoticeBar.tsx deleted file mode 100644 index 388289c7e03..00000000000 --- a/apps/studio/components/interfaces/DiskManagement/ui/NoticeBar.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' -import { type ComponentProps, type ReactNode } from 'react' -import { cn } from 'ui' -import { Admonition } from 'ui-patterns' - -/** - * @deprecated Use Admonition from ui-patterns instead - * Pass actions as a prop to the Admonition component - */ - -interface NoticeBarProps extends Omit<ComponentProps<typeof Admonition>, 'description'> { - title?: string - description?: string - icon?: ReactNode - visible: boolean - actions?: ReactNode -} -export function NoticeBar({ visible, description, actions, ...props }: NoticeBarProps) { - return ( - <AnimatePresence> - {visible && ( - <motion.div - initial={{ opacity: 0, height: 0, y: 4 }} - animate={{ opacity: 1, height: 'auto', y: 0 }} - exit={{ opacity: 0, height: 0, y: 4 }} - transition={{ duration: 0.15 }} - > - <Admonition {...props} className={cn(props.className, 'mb-0')}> - {description} - <div className="flex flex-col gap-2"> - {actions && <div className="mt-2">{actions}</div>} - </div> - </Admonition> - </motion.div> - )} - </AnimatePresence> - ) -} diff --git a/apps/studio/components/interfaces/Docs/Introduction.tsx b/apps/studio/components/interfaces/Docs/Introduction.tsx index 49bc91da599..f98ce4365e2 100644 --- a/apps/studio/components/interfaces/Docs/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Introduction.tsx @@ -35,10 +35,7 @@ export default function Introduction({ selectedLang }: Props) { <p> All projects have a RESTful endpoint that you can use with your project's API key to query and manage your database. These can be obtained from the{' '} - <InlineLink href={`/project/${projectRef}/integrations/data_api/overview`}> - API settings - </InlineLink> - . + <InlineLink href={`/project/${projectRef}/settings/api-keys`}>API settings</InlineLink>. </p> <p> You can initialize a new Supabase client using the <code>createClient()</code> method. diff --git a/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx b/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx index f6a62bfb8aa..0059b22d52e 100644 --- a/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx +++ b/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' const PublicSchemaNotEnabledAlert = () => { const { ref: projectRef } = useParams() diff --git a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx index 6468599c6a1..7ce99765e42 100644 --- a/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx +++ b/apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header.tsx @@ -7,7 +7,7 @@ import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/L import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export interface ExplainSummary { totalTime: number diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx index 15258cb47ce..39e21839edb 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx @@ -86,7 +86,7 @@ export const EdgeFunctionInvocationsChart = ({ {updateAnnotation && ( <ReferenceLine x={updateAnnotation.timestamp} - stroke="hsl(var(--foreground-default))" + stroke="var(--foreground-default)" strokeDasharray="4 4" strokeWidth={1.5} /> diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx index db448f5e29c..5abbd9eed3c 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx @@ -22,7 +22,7 @@ import { } from './EdgeFunctionOverview.utils' import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFunctionOverview.utils' import { toAlertError } from './EdgeFunctionRecentErrors.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import type { ChartIntervals } from '@/types' interface EdgeFunctionInvocationsSectionProps { diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx index 14589b9d9b7..ac8d7c1dba4 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx @@ -22,7 +22,7 @@ import { EdgeFunctionRecentErrors } from './EdgeFunctionRecentErrors' import { EdgeFunctionUsageSection } from './EdgeFunctionUsageSection' import { useEdgeFunctionOverviewShortcuts } from './useEdgeFunctionOverviewShortcuts' import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { FunctionsCombinedStatsVariables, useFunctionsCombinedStatsQuery, @@ -247,5 +247,3 @@ export const EdgeFunctionOverview = () => { </> ) } - -export default EdgeFunctionOverview diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts index c773c098118..dfe484ea94c 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts @@ -105,7 +105,7 @@ export const CPU_TIME_CHART_CONFIG = { export const EXECUTION_TIME_CHART_CONFIG = { avg_execution_time: { label: 'Average Execution Time', - color: 'hsl(var(--foreground-default))', + color: 'var(--foreground-default)', }, max_execution_time: { label: 'Max Execution Time', diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx index bd837b5ce60..13694aae2e2 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx @@ -86,7 +86,7 @@ export const EdgeFunctionPerformanceSection = ({ { y: averageExecutionTime, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx index ac48e0c7ffc..c58c800761e 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx @@ -45,8 +45,8 @@ import { } from './EdgeFunctionRecentErrors.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' -import AlertError from '@/components/ui/AlertError' -import useLogsQuery from '@/hooks/analytics/useLogsQuery' +import { AlertError } from '@/components/ui/AlertError' +import { useLogsQuery } from '@/hooks/analytics/useLogsQuery' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' @@ -94,7 +94,8 @@ export const EdgeFunctionRecentErrors = ({ iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, }, - isQueryEnabled + isQueryEnabled, + { useOtel: true } ) const recentErrorGroupsBase = useMemo( @@ -112,7 +113,8 @@ export const EdgeFunctionRecentErrors = ({ iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, }, - Boolean(projectRef && sinceLastDeployInvocationCountSql && isoTimestampStart) + Boolean(projectRef && sinceLastDeployInvocationCountSql && isoTimestampStart), + { useOtel: true } ) const relatedExecutionIds = useMemo( @@ -136,7 +138,8 @@ export const EdgeFunctionRecentErrors = ({ iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, }, - Boolean(projectRef && functionRuntimeLogsSql && isoTimestampStart) + Boolean(projectRef && functionRuntimeLogsSql && isoTimestampStart), + { useOtel: true } ) const queryError = toAlertError(recentErrorInvocationsError) ?? toAlertError(functionRuntimeLogsError) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts index 5200cdda492..6486cd69e1b 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts @@ -10,6 +10,7 @@ import { getNoErrorsSinceLastDeployMessage, getRecentErrorGroups, getRecentErrorGroupsBase, + getRecentErrorInvocationsSql, getRelatedExecutionIds, getSinceLastDeployInvocationCount, getSinceLastDeployInvocationCountSql, @@ -47,10 +48,37 @@ describe('EdgeFunctionRecentErrors.utils', () => { executionIds: ['exec_1', "exec_'2"], limit: 25, }) - ) - .toBe(`select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs -cross join unnest(metadata) as metadata -where metadata.function_id = 'fn_''123' and metadata.execution_id in ('exec_1', 'exec_''2') + ).toBe(`-- runtime logs for error groups +select + toUnixTimestamp64Micro(timestamp) as timestamp, + event_message, + log_attributes['level'] as level, + log_attributes['event_type'] as event_type, + log_attributes['function_id'] as function_id, + log_attributes['execution_id'] as execution_id +from logs +where + source = 'function_logs' + and log_attributes['function_id'] = 'fn_''123' + and log_attributes['execution_id'] in ('exec_1', 'exec_''2') +order by timestamp desc +limit 25`) + }) + + it('builds recent error invocations SQL and escapes the function id', () => { + expect(getRecentErrorInvocationsSql("fn_'123", 25)).toBe(`-- errors since last deploy +select + toUnixTimestamp64Micro(timestamp) as timestamp, + event_message, + log_attributes['request.method'] as method, + log_attributes['response.status_code'] as status_code, + toFloat64OrZero(log_attributes['execution_time_ms']) as execution_time_ms, + log_attributes['execution_id'] as execution_id +from logs +where + source = 'function_edge_logs' + and log_attributes['function_id'] = 'fn_''123' + and toInt32OrZero(log_attributes['response.status_code']) >= 500 order by timestamp desc limit 25`) }) @@ -83,9 +111,11 @@ limit 25`) it('builds the since-deploy invocation count query and empty-state message', () => { expect(getSinceLastDeployInvocationCountSql()).toContain( - 'SELECT count(*) as count FROM function_edge_logs' + "select count() as count from logs where source = 'function_edge_logs'" + ) + expect(getSinceLastDeployInvocationCountSql()).toContain( + "log_attributes['function_id'] = '__pending__'" ) - expect(getSinceLastDeployInvocationCountSql()).toContain("(`function_id` = '__pending__')") expect( getSinceLastDeployInvocationCount([ diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts index d404963428b..a52fa09cdb4 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts @@ -2,11 +2,8 @@ import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { parseEdgeFunctionEventMessage } from '../EdgeFunctionRecentInvocations.utils' -import { LOGS_TABLES } from '@/components/interfaces/Settings/Logs/Logs.constants' import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types' import { - genCountQuery, - genDefaultQuery, isUnixMicro, unixMicroToIsoTimestamp, } from '@/components/interfaces/Settings/Logs/Logs.utils' @@ -196,20 +193,32 @@ export const getStatusBadgeVariant = (statusCode?: string) => { export const getRecentErrorInvocationsSql = ( functionId?: string, limit = RECENT_ERROR_INVOCATIONS_LIMIT -) => - genDefaultQuery( - LOGS_TABLES.fn_edge, - { - function_id: functionId ?? '__pending__', - 'status_code.error': true, - }, - limit - ) +): string => { + const id = escapeSqlString(functionId ?? '__pending__') + return ` +-- errors since last deploy +select + toUnixTimestamp64Micro(timestamp) as timestamp, + event_message, + log_attributes['request.method'] as method, + log_attributes['response.status_code'] as status_code, + toFloat64OrZero(log_attributes['execution_time_ms']) as execution_time_ms, + log_attributes['execution_id'] as execution_id +from logs +where + source = 'function_edge_logs' + and log_attributes['function_id'] = '${id}' + and toInt32OrZero(log_attributes['response.status_code']) >= 500 +order by timestamp desc +limit ${limit} +`.trim() +} -export const getSinceLastDeployInvocationCountSql = (functionId?: string) => - genCountQuery(LOGS_TABLES.fn_edge, { - function_id: functionId ?? '__pending__', - }) +export const getSinceLastDeployInvocationCountSql = (functionId?: string): string => { + const id = escapeSqlString(functionId ?? '__pending__') + return `-- invocation count since last deploy +select count() as count from logs where source = 'function_edge_logs' and log_attributes['function_id'] = '${id}'` +} export const getSinceLastDeployInvocationCount = (invocationCountRows: LogData[]) => { const count = Number(invocationCountRows[0]?.count ?? 0) @@ -238,16 +247,29 @@ export const getFunctionRuntimeLogsSql = ({ functionId?: string executionIds: string[] limit?: number -}) => { +}): string => { if (!functionId || executionIds.length === 0) return '' + const escapedFunctionId = escapeSqlString(functionId) const escapedExecutionIds = executionIds.map((id) => `'${escapeSqlString(id)}'`).join(', ') - return `select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs -cross join unnest(metadata) as metadata -where metadata.function_id = '${escapeSqlString(functionId)}' and metadata.execution_id in (${escapedExecutionIds}) + return ` +-- runtime logs for error groups +select + toUnixTimestamp64Micro(timestamp) as timestamp, + event_message, + log_attributes['level'] as level, + log_attributes['event_type'] as event_type, + log_attributes['function_id'] as function_id, + log_attributes['execution_id'] as execution_id +from logs +where + source = 'function_logs' + and log_attributes['function_id'] = '${escapedFunctionId}' + and log_attributes['execution_id'] in (${escapedExecutionIds}) order by timestamp desc -limit ${limit}` +limit ${limit} +`.trim() } export const getRecentErrorGroupsBase = ( diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx index 1de5fbffd0c..99f4206a22f 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx @@ -115,7 +115,7 @@ export const EdgeFunctionUsageSection = ({ { y: averageCpuTime, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} @@ -140,7 +140,7 @@ export const EdgeFunctionUsageSection = ({ { y: averageMemoryUsage, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx index 399a8692a88..e88ccfffdac 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx @@ -3,8 +3,9 @@ import { Clock, ExternalLink, RefreshCw } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { Button, cn } from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils' import { LOGS_TABLES } from '@/components/interfaces/Settings/Logs/Logs.constants' diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 510261b96d4..8dd395def72 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -15,7 +15,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import type { ProjectSecret } from '@/data/secrets/secrets-query' diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx index 766d4e120be..c8e31584ba5 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx @@ -5,7 +5,8 @@ import { Check, Copy } from 'lucide-react' import { useRouter } from 'next/router' import { useMemo, useState, type MouseEvent } from 'react' import { cn, copyToClipboard, TableCell, TableRow } from 'ui' -import { ShimmeringLoader, TimestampInfo } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { formatErrorRate } from './EdgeFunctionsListItem.utils' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' diff --git a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx index 95f379cbfcd..e8be56338ff 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx @@ -13,7 +13,7 @@ import { TableHeader, TableRow, } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { ShimmeringCard } from './ShimmeringCard' import { HomeIcon } from '@/components/layouts/Navigation/LayoutHeader/HomeIcon' diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index be8d7986a29..9fd6fdf81a2 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -25,7 +25,7 @@ import { toTableHeadSortValue, } from './ProjectListSort.utils' import { ProjectTableRow } from './ProjectTableRow' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { NoSearchResults } from '@/components/ui/NoSearchResults' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only' diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 8a0e8700e1d..3ab9ae62a6b 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -13,7 +13,7 @@ import { TableCell, TableRow, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { inferProjectStatus } from './ProjectCard.utils' import { ProjectCardStatus } from './ProjectCardStatus' diff --git a/apps/studio/components/interfaces/Home/ServiceStatus.tsx b/apps/studio/components/interfaces/Home/ServiceStatus.tsx deleted file mode 100644 index 367f2f6e58f..00000000000 --- a/apps/studio/components/interfaces/Home/ServiceStatus.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { useParams } from 'common' -import dayjs from 'dayjs' -import { AlertTriangle, CheckCircle2, ChevronRight, Loader2 } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useState } from 'react' -import { Button, InfoIcon, Popover, PopoverContent, PopoverTrigger } from 'ui' - -import { InlineLink } from '@/components/ui/InlineLink' -import { useBranchesQuery } from '@/data/branches/branches-query' -import { useEdgeFunctionServiceStatusQuery } from '@/data/service-status/edge-functions-status-query' -import { - ProjectServiceStatus as APIProjectServiceStatus, - ServiceHealthResponse, - useProjectServiceStatusQuery, -} from '@/data/service-status/service-status-query' -import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL } from '@/lib/constants' - -const SERVICE_STATUS_THRESHOLD = 5 // minutes - -export type ProjectServiceStatus = APIProjectServiceStatus | 'DISABLED' - -export const StatusMessage = ({ - status, - isLoading, - isProjectNew, -}: { - isLoading: boolean - isProjectNew: boolean - status?: ProjectServiceStatus -}) => { - if (isLoading) return 'Checking status' - if (status === 'DISABLED') return 'Disabled' - if (status === 'UNHEALTHY') return 'Unhealthy' - if (status === 'COMING_UP') return 'Coming up...' - if (status === 'ACTIVE_HEALTHY') return 'Healthy' - // isProjectNew has to be after all other statuses - if (isProjectNew) return 'Coming up...' - if (status) return status - return 'Unable to connect' -} - -const iconProps = { - size: 18, - strokeWidth: 1.5, -} -const LoaderIcon = () => <Loader2 {...iconProps} className="animate-spin" /> -const AlertIcon = () => <AlertTriangle {...iconProps} /> -const CheckIcon = () => <CheckCircle2 {...iconProps} className="text-brand" /> - -export const StatusIcon = ({ - isLoading, - isProjectNew, - projectStatus, -}: { - isLoading: boolean - isProjectNew: boolean - projectStatus?: ProjectServiceStatus -}) => { - // - if (projectStatus === 'ACTIVE_HEALTHY') return <CheckIcon /> - if (projectStatus === 'DISABLED') return <AlertIcon /> - if (projectStatus === 'COMING_UP') return <LoaderIcon /> - if (isLoading) return <LoaderIcon /> - // isProjectNew has to be above UNHEALTHY because in the first few minutes, some services might be starting up and show as UNHEALTHY - if (isProjectNew) return <LoaderIcon /> - if (projectStatus === 'UNHEALTHY') return <AlertIcon /> - return <AlertIcon /> -} - -/* - * Extract the db_schema from the response.info object - */ -export const extractDbSchema = (response: ServiceHealthResponse | undefined) => { - if (response?.info && 'db_schema' in response.info) { - return response.info.db_schema - } - return undefined -} - -export const ServiceStatus = () => { - const { ref } = useParams() - const { data: project } = useSelectedProjectQuery() - const [open, setOpen] = useState(false) - - const { - projectAuthAll: authEnabled, - projectEdgeFunctionAll: edgeFunctionsEnabled, - realtimeAll: realtimeEnabled, - projectStorageAll: storageEnabled, - } = useIsFeatureEnabled([ - 'project_auth:all', - 'project_edge_function:all', - 'realtime:all', - 'project_storage:all', - ]) - - const isBranch = project?.parentRef !== project?.ref - - // Get branches data when on a branch - const { data: branches, isPending: isBranchesLoading } = useBranchesQuery( - { projectRef: isBranch ? project?.parentRef : undefined }, - { - enabled: isBranch, - refetchInterval: (query) => { - const data = query.state.data - if (!data) return false - const currentBranch = data.find((branch) => branch.project_ref === ref) - return ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes( - currentBranch?.status || '' - ) - ? false - : 5000 - }, - } - ) - - const currentBranch = isBranch - ? branches?.find((branch) => branch.project_ref === ref) - : undefined - - // [Joshen] Need pooler service check eventually - const { - data: status, - isPending: isLoading, - refetch: refetchServiceStatus, - } = useProjectServiceStatusQuery( - { - projectRef: ref, - }, - { - refetchInterval: (query) => { - const data = query.state.data - const isServiceUnhealthy = data?.some((service) => { - // if the postgrest service has an empty schema, the user chose to turn off postgrest during project creation - if (service.name === 'rest' && extractDbSchema(service) === '') { - return false - } - if (service.status === 'ACTIVE_HEALTHY') { - return false - } - return true - }) - - return isServiceUnhealthy ? 5000 : false - }, - } - ) - const { data: edgeFunctionsStatus, refetch: refetchEdgeFunctionServiceStatus } = - useEdgeFunctionServiceStatusQuery( - { - projectRef: ref, - }, - { - refetchInterval: (query) => { - const data = query.state.data - return !data?.healthy ? 5000 : false - }, - } - ) - - const authStatus = status?.find((service) => service.name === 'auth') - const restStatus = status?.find((service) => service.name === 'rest') - const realtimeStatus = status?.find((service) => service.name === 'realtime') - const storageStatus = status?.find((service) => service.name === 'storage') - const dbStatus = status?.find((service) => service.name === 'db') - - // [Joshen] Need individual troubleshooting docs for each service eventually for users to self serve - const services: { - name: string - error?: string - docsUrl?: string - isLoading: boolean - status: ProjectServiceStatus - logsUrl: string - }[] = [ - { - name: 'Database', - error: undefined, - docsUrl: undefined, - isLoading: isLoading, - status: dbStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/postgres-logs', - }, - { - name: 'PostgREST', - error: restStatus?.error, - docsUrl: undefined, - isLoading, - // If PostgREST has an empty schema, it means it's been disabled - status: extractDbSchema(restStatus) === '' ? 'DISABLED' : (restStatus?.status ?? 'UNHEALTHY'), - logsUrl: '/logs/postgrest-logs', - }, - ...(authEnabled - ? [ - { - name: 'Auth', - error: authStatus?.error, - docsUrl: undefined, - isLoading, - status: authStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/auth-logs', - }, - ] - : []), - ...(realtimeEnabled - ? [ - { - name: 'Realtime', - error: realtimeStatus?.error, - docsUrl: undefined, - isLoading, - status: realtimeStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/realtime-logs', - }, - ] - : []), - ...(storageEnabled - ? [ - { - name: 'Storage', - error: storageStatus?.error, - docsUrl: undefined, - isLoading, - status: storageStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/storage-logs', - }, - ] - : []), - ...(edgeFunctionsEnabled - ? [ - { - name: 'Edge Functions', - error: undefined, - docsUrl: `${DOCS_URL}/guides/functions/troubleshooting`, - isLoading, - status: edgeFunctionsStatus?.healthy - ? 'ACTIVE_HEALTHY' - : isLoading - ? 'COMING_UP' - : ('UNHEALTHY' as ProjectServiceStatus), - logsUrl: '/logs/edge-functions-logs', - }, - ] - : []), - ...(isBranch - ? [ - { - name: 'Migrations', - error: undefined, - docsUrl: undefined, - isLoading: isBranchesLoading, - status: (currentBranch?.status === 'FUNCTIONS_DEPLOYED' - ? 'ACTIVE_HEALTHY' - : currentBranch?.status === 'FUNCTIONS_FAILED' || - currentBranch?.status === 'MIGRATIONS_FAILED' - ? 'UNHEALTHY' - : 'COMING_UP') as ProjectServiceStatus, - logsUrl: '/branches', - }, - ] - : []), - ] - - const isMigrationLoading = - isBranchesLoading || - currentBranch?.status === 'CREATING_PROJECT' || - currentBranch?.status === 'RUNNING_MIGRATIONS' - const isLoadingChecks = services.some((service) => service.isLoading) - // We consider a service operational if it's healthy or intentionally disabled - const allServicesOperational = services.every( - (service) => service.status === 'ACTIVE_HEALTHY' || service.status === 'DISABLED' - ) - - // If the project is less than 5 minutes old, and status is not operational, then it's likely the service is still starting up - const isProjectNew = - dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < SERVICE_STATUS_THRESHOLD || - project?.status === 'COMING_UP' - - useEffect(() => { - let timer: any - - if (isProjectNew) { - const secondsSinceProjectCreated = dayjs - .utc() - .diff(dayjs.utc(project?.inserted_at), 'seconds') - const remainingTimeTillNextCheck = SERVICE_STATUS_THRESHOLD * 60 - secondsSinceProjectCreated - - timer = setTimeout(() => { - refetchServiceStatus() - refetchEdgeFunctionServiceStatus() - }, remainingTimeTillNextCheck * 1000) - } - - return () => { - clearTimeout(timer) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isProjectNew]) - - return ( - <Popover modal={false} open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button - variant="default" - icon={ - isLoadingChecks || (!allServicesOperational && isProjectNew && isMigrationLoading) ? ( - <LoaderIcon /> - ) : ( - <div - className={`w-2 h-2 rounded-full ${ - allServicesOperational ? 'bg-brand' : 'bg-warning' - }`} - /> - ) - } - > - {isBranch ? 'Branch' : 'Project'} Status - </Button> - </PopoverTrigger> - <PopoverContent className="p-0 w-56" side="bottom" align="center"> - {services.map((service) => ( - <Link - href={`/project/${ref}${service.logsUrl}`} - key={service.name} - className="transition px-3 py-2 text-xs flex items-center justify-between border-b last:border-none group relative hover:bg-surface-300" - > - <div className="flex gap-x-2"> - <StatusIcon - isLoading={service.isLoading} - isProjectNew={isProjectNew} - projectStatus={service.status} - /> - <div className="flex-1"> - <p>{service.name}</p> - <p className="text-foreground-light flex items-center gap-1"> - <StatusMessage - isLoading={service.isLoading} - isProjectNew={isProjectNew} - status={service.status} - /> - </p> - </div> - </div> - <div className="flex items-center gap-x-1 transition opacity-0 group-hover:opacity-100"> - <span className="text-xs text-foreground">View logs</span> - <ChevronRight size={14} className="text-foreground" /> - </div> - </Link> - ))} - {!allServicesOperational && ( - <div className="flex gap-2 text-xs text-foreground-light px-3 py-2"> - <div className="mt-0.5"> - <InfoIcon /> - </div> - <div className="flex flex-col gap-y-1"> - <p> - {isProjectNew ? 'New' : 'Recently restored'} projects can take up to{' '} - {SERVICE_STATUS_THRESHOLD} minutes to become fully operational. - </p> - <p> - If services stay unhealthy, refer to our{' '} - <InlineLink - href={`${DOCS_URL}/guides/troubleshooting/project-status-reports-unhealthy-services`} - > - docs - </InlineLink>{' '} - for more information. - </p> - </div> - </div> - )} - </PopoverContent> - </Popover> - ) -} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx index bee4c722762..bacefa744fc 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx @@ -35,8 +35,8 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { CodeBlock } from 'ui-patterns/CodeBlock' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { useDatabaseCronJobRunCommandMutation } from '@/data/database-cron-jobs/database-cron-job-run-mutation' import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx index d5d3c468247..14cca6a3651 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx @@ -3,7 +3,7 @@ import DataGrid, { Row, type Column } from 'react-data-grid' import { cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import type { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' import type { ResponseError } from '@/types' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EstimateErrorNotice.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EstimateErrorNotice.tsx deleted file mode 100644 index fa732dd735a..00000000000 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EstimateErrorNotice.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' - -interface CronJobRunDetailsEstimateErrorNoticeProps { - error?: Error | null - isRetrying?: boolean - onRetry?: () => void -} - -export const CronJobRunDetailsEstimateErrorNotice = ({ - error, - isRetrying, - onRetry, -}: CronJobRunDetailsEstimateErrorNoticeProps) => { - return ( - <Admonition - type="warning" - title="Error displaying cron jobs" - description="There was an error displaying cron jobs. Please try again." - className="max-w-3xl w-full" - > - <div className="space-y-3 text-sm"> - {error?.message && ( - <p className="text-foreground-light wrap-break-word"> - Error message: <code className="text-xs">{error.message}</code> - </p> - )} - {onRetry && ( - <Button - variant="default" - loading={isRetrying} - disabled={isRetrying} - className="mt-1" - onClick={onRetry} - > - Retry check - </Button> - )} - </div> - </Admonition> - ) -} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx index 54ebfc7e798..75c65f5d2f8 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx @@ -4,9 +4,9 @@ import { CircleCheck, CircleX, Loader } from 'lucide-react' import { useMemo } from 'react' import DataGrid, { Column, Row } from 'react-data-grid' import { cn, LoadingLine, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { CodeBlock } from 'ui-patterns/CodeBlock' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { calculateDuration, formatDate } from './CronJobs.utils' import CronJobsEmptyState from './CronJobsEmptyState' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx index 6f6850121d8..a58c636f2e4 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx @@ -4,7 +4,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants' import FunctionSelector from '@/components/ui/FunctionSelector' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' interface SqlFunctionSectionProps { form: UseFormReturn<CreateCronJobForm> diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocViewLoading.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocViewLoading.tsx index 87d27d4d4fa..619b13cd779 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DocViewLoading.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DocViewLoading.tsx @@ -1,4 +1,4 @@ -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' export const DocViewLoading = () => { return ( diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx index 3e8af99f7f8..53e2f634cd9 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { useMemo, useState } from 'react' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { ShowApiKey } from '../../Docs/Docs.types' import { LangSelector } from '../../Docs/LangSelector' diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index e6525e61d85..b5a29d8f9e9 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -1,5 +1,5 @@ -import 'graphiql/style.css' import 'graphiql/setup-workers/webpack' +import './graphiql-styles.css' import { useMonaco, type GraphiQLPlugin } from '@graphiql/react' import { createGraphiQLFetcher, Fetcher } from '@graphiql/toolkit' @@ -19,6 +19,7 @@ import { IntrospectionEnabledNotice } from './IntrospectionEnabledNotice' import { usePgGraphqlIntrospectionStatus } from './usePgGraphqlIntrospectionStatus' import { getTheme } from '@/components/interfaces/App/MonacoThemeProvider' import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector' +import { BASE_MONACO_EDITOR_OPTIONS } from '@/components/ui/CodeEditor/CodeEditor.utils' import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query' import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -33,36 +34,46 @@ const ROLE_IMPERSONATION_PLUGIN: GraphiQLPlugin = { content: () => <RoleImpersonationSelector orientation="vertical" />, } -const MONACO_THEME = { dark: 'supabase-graphql-dark', light: 'supabase-graphql-light' } - -const GraphiQLMonacoTheme = ({ resolvedTheme }: { resolvedTheme: 'dark' | 'light' }) => { +/** + * GraphiQL (@graphiql/react) bundles its own Monaco instance, separate from the AMD-loaded one + * the rest of Studio uses — they only share the global `.monaco-*` CSS class names, not JS state. + * So theme/options applied here via @graphiql/react's `useMonaco` touch GraphiQL's editors only + * and never leak onto Studio's editors + */ +const GraphiQLEditorSettings = ({ theme }: { theme: 'dark' | 'light' }) => { const { monaco } = useMonaco() useEffect(() => { if (!monaco) return - const dark = getTheme('dark') - const light = getTheme('light') - monaco.editor.defineTheme(MONACO_THEME.dark, { - ...dark, - rules: [...dark.rules, { token: 'argument.identifier.gql', foreground: '908aff' }], - }) - monaco.editor.defineTheme(MONACO_THEME.light, { - ...light, - rules: [...light.rules, { token: 'argument.identifier.gql', foreground: '6c69ce' }], - // Match the dashboard's bg-default in light mode so the editor doesn't read - // as a darker square against the surrounding UI. - colors: { ...light.colors, 'editor.background': '#fcfcfc' }, - }) - monaco.editor.setTheme(MONACO_THEME[resolvedTheme]) - }, [monaco, resolvedTheme]) + monaco.editor.defineTheme('supabase', getTheme(theme)) + monaco.editor.setTheme('supabase') + }, [monaco, theme]) + + useEffect(() => { + if (!monaco) return + const options = { + ...BASE_MONACO_EDITOR_OPTIONS, + padding: { top: 16, bottom: 16 }, + glyphMargin: true, + } + const applyToEditor = (editor: ReturnType<typeof monaco.editor.getEditors>[number]) => + editor.updateOptions(options) + + monaco.editor.getEditors().forEach(applyToEditor) + const disposable = monaco.editor.onDidCreateEditor(applyToEditor) + + return () => disposable.dispose() + }, [monaco]) return null } export const GraphiQLTab = () => { - const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() + + const { resolvedTheme } = useTheme() const currentTheme = resolvedTheme?.includes('dark') ? 'dark' : 'light' + const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) const { data: project } = useSelectedProjectQuery() @@ -144,7 +155,7 @@ export const GraphiQLTab = () => { return ( <div className="flex flex-col h-full"> - <GraphiQLMonacoTheme resolvedTheme={currentTheme} /> + <GraphiQLEditorSettings theme={currentTheme} /> {notice === 'opt-in' && ( <IntrospectionDisabledNotice schema={DEFAULT_INTROSPECTION_SCHEMA} @@ -164,7 +175,7 @@ export const GraphiQLTab = () => { key={graphiqlKey} fetcher={fetcher} forcedTheme={currentTheme} - editorTheme={MONACO_THEME} + editorTheme={{ dark: 'supabase', light: 'supabase' }} className={styles.root} plugins={plugins} /> diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/graphiql-styles.css b/apps/studio/components/interfaces/Integrations/GraphQL/graphiql-styles.css new file mode 100644 index 00000000000..7d157c90be9 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/graphiql-styles.css @@ -0,0 +1,19 @@ +/* + * GraphiQL v5 bundles a full copy of Monaco's CSS into its stylesheet. Studio's + * SQL editor (and every other Monaco instance) shares the same global Monaco + * instance, and those `.monaco-*` rules are unscoped, so importing GraphiQL's + * stylesheet directly leaks its Monaco theme onto the SQL editor — whichever + * stylesheet happens to load last wins the cascade. + * + * Importing it into a low-priority cascade layer fixes this without touching the + * vendored bundle: Studio's own Monaco styles (the runtime-injected monaco-editor + * CSS and styles/monaco.css) are unlayered, and unlayered rules always beat any + * layered rule. So Studio's Monaco styling wins everywhere — including inside + * GraphiQL's editor, which is the consistent behavior we want. + * + * GraphiQL's own UI (the `.graphiql-container` selectors) still applies: the + * `graphiql` layer is registered after Tailwind's layers (this file loads after + * globals.css), so it outranks Tailwind's base/component resets just as the + * unlayered import did before. + */ +@import 'graphiql/style.css' layer(graphiql); diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css b/apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css index ff28fedbefd..9dd88c5be62 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css +++ b/apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css @@ -24,6 +24,7 @@ .root :global(.graphiql-horizontal-drag-bar) { @apply border-l border-default; + background-color: var(--graphiql-response-bg); } .root :global(.graphiql-horizontal-drag-bar:hover::after) { @@ -34,6 +35,27 @@ border-left: 0; } +/* Restore Monaco's intended clipping of the editor body. Monaco renders the gutter as an + * oversized, scroll-offset `.margin` (absolutely positioned, opaque background) and relies + * on `.overflow-guard { overflow: hidden }` to clip it to the editor box. Here the guard + * ends up `overflow: visible` via an inline style Monaco's layout sets at runtime, so the + * white gutter escapes the editor and paints over the page above it. An inline style beats + * any external selector regardless of specificity, so `!important` is required to win — it + * is not a specificity shortcut here. Scoped to `.root` so the SQL editor's Monaco is + * untouched. Autocomplete/hover popups live in `.overflowingContentWidgets` (a sibling of + * `.overflow-guard`), so they are not clipped. */ +.root :global(.monaco-editor .overflow-guard) { + @apply !overflow-hidden; +} + +/* Drop GraphiQL's default 16px padding around the query editor so the editor sits flush to + * its container. This keeps Monaco's scroll shadow (`.scroll-decoration`, pinned to the + * editor's top edge) full-bleed instead of floating inset. The content's own breathing room + * is restored via Monaco's `padding`/`glyphMargin` options in GraphiQLTab.tsx. */ +.root :global(.graphiql-query-editor) { + @apply p-0; +} + /* Lift the response panel above the editor surface so the two read as separate panes. * The token differs per theme because the editor sits at a different baseline lightness * in each (~12% L in dark, ~94% L in light), so a single surface token can't lift it @@ -42,17 +64,10 @@ .root :global(.graphiql-response .monaco-editor), .root :global(.graphiql-response .monaco-editor .margin), .root :global(.graphiql-response .monaco-editor .monaco-editor-background) { + @apply pt-0; background-color: var(--graphiql-response-bg) !important; } -/* Editor-to-response drag bar: blend it into the response side so it doesn't read as - * a third color sandwiched between the two panes. The plugin-to-main drag bar is left - * alone — it sits next to the editor surface and our default border there is fine. */ -.root :global(.graphiql-editors + .graphiql-horizontal-drag-bar) { - background-color: var(--graphiql-response-bg); - border-left: 0; -} - .root :global(.graphiql-button), .root :global(button.graphiql-button), .root :global(.graphiql-un-styled), @@ -84,21 +99,20 @@ --border-radius-8: 0; --border-radius-12: 0; --popover-box-shadow: none; + /* Theme-independent: the Supabase green, and a response surface whose own token + * (--background-surface-300) already resolves per theme — so only --color-base below + * needs to branch on light/dark. */ + --color-primary: 153, 50%, 50%; + --graphiql-response-bg: var(--background-surface-300); } :global(body.graphiql-dark) .root { /* HSL form of #1f1f1f, matches the editor.background returned by getTheme('dark') */ --color-base: 0, 0%, 12%; - --color-primary: 153, 50%, 50%; - --graphiql-response-bg: hsl(var(--background-surface-300)); } :global(body.graphiql-light) .root { - /* HSL form of #fcfcfc, matches the dashboard bg-default and the editor.background - * we override to in GraphiQLTab.tsx so the editor reads as part of the surrounding UI. */ + /* HSL form of #fcfcfc, matches the dashboard bg-default so the editor reads as part + * of the surrounding UI. */ --color-base: 0, 0%, 98.8%; - --color-primary: 153, 50%, 50%; - /* Drop the response below the editor in light mode (inverse of dark) so it reads as - * the lower / "data" surface rather than the lifted one. */ - --graphiql-response-bg: hsl(var(--background-surface-300)); } diff --git a/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx index 1b70579d7e6..b4d4a519d35 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx @@ -1,6 +1,6 @@ import { Settings, Trash2, TriangleAlert } from 'lucide-react' import { Badge, Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { type ResourceGroup } from './MarketplaceIntegrationSettingsTab.types' import { type ConnectedResource } from '@/components/interfaces/Integrations/Landing/Landing.utils' diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index.tsx deleted file mode 100644 index d67884ead4a..00000000000 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useParams } from 'common' -import { PropsWithChildren } from 'react' -import { cn } from 'ui' - -import { useAvailableIntegrations } from '../../Landing/useAvailableIntegrations' -import { FilesViewer } from './FilesViewer' -import { MarkdownContent } from './MarkdownContent' -import { InlineLinkClassName } from '@/components/ui/InlineLink' - -const getSiteUrlLabel = (url: string | undefined | null) => { - if (!url) return undefined - - try { - return new URL(url).origin - } catch (error) { - return undefined - } -} - -const isGithubHost = (url: string | undefined | null) => { - if (!url) return false - try { - const hostname = new URL(url).hostname.toLowerCase() - return hostname === 'github.com' || hostname.endsWith('.github.com') - } catch (error) { - return false - } -} - -/** - * [Joshen] This will serve as the overview tab for remotely fetched integrations - */ -export const IntegrationOverviewTabV2 = ({ children }: PropsWithChildren) => { - const { id } = useParams() - - const { data: allIntegrations } = useAvailableIntegrations() - const integration = allIntegrations.find((i) => i.id === id) - - if (!integration) { - return <div>Unsupported integration type</div> - } - - const { type, content, docsUrl, siteUrl, files = [] } = integration - - const docsUrlLabel = docsUrl?.includes('supabase.com/docs') - ? 'Supabase Docs' - : isGithubHost(docsUrl) - ? 'GitHub Docs' - : 'Documentation' - const siteUrlLabel = getSiteUrlLabel(siteUrl) - - return ( - <div className="grid grid-cols-3 gap-x-8 px-10 py-8"> - <div className="col-span-2 flex flex-col gap-y-8"> - {files.length > 0 && <FilesViewer files={files} />} - <MarkdownContent integrationId={id} content={content} /> - {children} - </div> - - <div className="text-sm col-span-1 flex flex-col gap-y-8"> - <div className="flex flex-col gap-y-4"> - <p>Details</p> - - <div className="flex flex-col gap-y-1"> - <p className="font-mono uppercase text-foreground-light">Type</p> - <p className="capitalize">{type === 'oauth' ? 'OAuth' : type.replaceAll('_', ' ')}</p> - </div> - - <div className="flex flex-col gap-y-1"> - <p className="font-mono uppercase text-foreground-light">Built by</p> - <p className={cn(!integration.author.name && 'text-foreground-lighter')}> - {integration.author.name || 'Unknown Author'} - </p> - </div> - - {docsUrl && ( - <div className="flex flex-col gap-y-1"> - <p className="font-mono uppercase text-foreground-light">Docs</p> - <a target="_blank" rel="noreferrer" href={docsUrl} className={InlineLinkClassName}> - {docsUrlLabel} - </a> - </div> - )} - - {siteUrl && ( - <div className="flex flex-col gap-y-1"> - <p className="font-mono uppercase text-foreground-light">Website</p> - <a target="_blank" rel="noreferrer" href={siteUrl} className={InlineLinkClassName}> - {siteUrlLabel} - </a> - </div> - )} - </div> - - {/* Subsequent other actions here */} - </div> - </div> - ) -} diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx deleted file mode 100644 index 5369bef2347..00000000000 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * [Joshen] This is just to dynamically render either the V1 or V2 Overview tab based - * on the marketplace feature flag - */ - -import { PropsWithChildren } from 'react' - -import { IntegrationOverviewTab, IntegrationOverviewTabProps } from './IntegrationOverviewTab' -import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' - -export const IntegrationOverviewTabWrapper = ( - props: PropsWithChildren<IntegrationOverviewTabProps> -) => { - const isMarketplaceEnabled = useIsMarketplaceEnabled() - - if (isMarketplaceEnabled) { - return null - } - - return <IntegrationOverviewTab {...props} /> -} diff --git a/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx b/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx index ac55db43495..41281c2bf3c 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx @@ -9,9 +9,9 @@ import { NavMenu, NavMenuItem, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { PageContainer } from 'ui-patterns/PageContainer' import { - Admonition, - PageContainer, PageHeader, PageHeaderBreadcrumb, PageHeaderDescription, @@ -20,10 +20,9 @@ import { PageHeaderNavigationTabs, PageHeaderSummary, PageHeaderTitle, - PageSection, - PageSectionContent, -} from 'ui-patterns' -import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +} from 'ui-patterns/PageHeader' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { IntegrationLogo } from '@/components/interfaces/Integrations/Integration/IntegrationLogo' import { InstallOAuthIntegrationButton } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton' @@ -135,7 +134,7 @@ const LegacyIntegrationPage = () => { )} </PageHeader> - <div className="mx-auto w-full">{content}</div> + <div className="flex-1 min-h-0 mx-auto w-full">{content}</div> </> ) } diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx index 3937defb9dc..414eb7eb440 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' import { toast } from 'sonner' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ResourceGroupSection } from './ConnectedResourceGroupSection' import { type ApiKeyResource, type ResourceGroup } from './MarketplaceIntegrationSettingsTab.types' diff --git a/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx deleted file mode 100644 index 64dea3eec13..00000000000 --- a/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Search } from 'lucide-react' -import { parseAsString, useQueryState } from 'nuqs' -import { buttonVariants, cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' -import { Admonition } from 'ui-patterns/admonition' -import { Input } from 'ui-patterns/DataInputs/Input' - -import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard' -import { useAvailableIntegrations } from './useAvailableIntegrations' -import { useInstalledIntegrations } from './useInstalledIntegrations' -import AlertError from '@/components/ui/AlertError' -import { NoSearchResults } from '@/components/ui/NoSearchResults' -import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' - -type IntegrationCategory = 'all' | 'wrapper' | 'postgres_extensions' | 'custom' -const CATEGORIES = [ - { key: 'all', label: 'All Integrations' }, - { key: 'wrapper', label: 'Wrappers' }, - { key: 'postgres_extension', label: 'Postgres Modules' }, -] as const - -export const AvailableIntegrations = () => { - const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) - - const [selectedCategory, setSelectedCategory] = useQueryState( - 'category', - parseAsString.withDefault('all').withOptions({ clearOnDefault: true }) - ) - const [search, setSearch] = useQueryState( - 'search', - parseAsString.withDefault('').withOptions({ clearOnDefault: true }) - ) - - const { data: allIntegrations = [] } = useAvailableIntegrations() - const { installedIntegrations, error, isError, isLoading, isSuccess } = useInstalledIntegrations() - - const installedIds = installedIntegrations.map((i) => i.id) - - // available integrations for install - const availableIntegrations = integrationsWrappers - ? allIntegrations - : allIntegrations.filter((x) => !x.id.endsWith('_wrapper')) - const integrationsByCategory = - selectedCategory === 'all' - ? availableIntegrations - : availableIntegrations.filter((i) => i.type === selectedCategory) - - const filteredIntegrations = ( - search.length > 0 - ? integrationsByCategory.filter((i) => i.name.toLowerCase().includes(search.toLowerCase())) - : integrationsByCategory - ).sort((a, b) => a.name.localeCompare(b.name)) - - return ( - <> - <Tabs_Shadcn_ - className="mt-4" - value={selectedCategory} - onValueChange={(value) => setSelectedCategory(value as IntegrationCategory)} - > - <TabsList_Shadcn_ className="px-4 md:px-10 gap-2 border-b-0 border-t pt-5"> - {CATEGORIES.map((category) => ( - <TabsTrigger_Shadcn_ - key={category.key} - value={category.key} - onClick={() => setSelectedCategory(category.key as IntegrationCategory)} - className={cn( - buttonVariants({ - size: 'tiny', - variant: selectedCategory === category.key ? 'default' : 'outline', - }), - selectedCategory === category.key ? 'text-foreground' : 'text-foreground-lighter', - 'rounded-full! px-3' - )} - > - {category.label} - </TabsTrigger_Shadcn_> - ))} - <Input - value={search} - onChange={(e) => { - setSearch(e.target.value) - setSelectedCategory('all') - }} - containerClassName="group w-40 ml-5" - icon={ - <Search - size={14} - className="transition text-foreground-lighter group-hover:text-foreground" - /> - } - iconContainerClassName="p-0" - className="pl-7 rounded-none border-0! border-transparent bg-transparent shadow-none! ring-0! ring-offset-0!" - placeholder="Search..." - /> - </TabsList_Shadcn_> - </Tabs_Shadcn_> - <div className="p-4 md:p-10 md:py-8 flex flex-col gap-y-5"> - <div className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3"> - {isLoading && - Array.from({ length: 3 }).map((_, idx) => ( - <IntegrationLoadingCard key={`integration-loading-${idx}`} /> - ))} - {isError && ( - <AlertError - className="xl:col-span-3 2xl:col-span-4" - subject="Failed to retrieve available integrations" - error={error} - /> - )} - {isSuccess && - filteredIntegrations.map((i) => ( - <IntegrationCard key={i.id} {...i} isInstalled={installedIds.includes(i.id)} /> - ))} - {isSuccess && search.length > 0 && filteredIntegrations.length === 0 && ( - <NoSearchResults - className="xl:col-span-3 2xl:col-span-4" - searchString={search} - onResetFilter={() => setSearch('')} - /> - )} - {isSuccess && - selectedCategory !== 'all' && - search.length === 0 && - filteredIntegrations.length === 0 && ( - <Admonition - showIcon={false} - className="xl:col-span-3 2xl:col-span-4" - type="default" - title="All integrations in this category are currently in use" - description="Manage your installed integrations in the section above" - /> - )} - </div> - </div> - </> - ) -} diff --git a/apps/studio/components/interfaces/Integrations/Landing/InstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/InstalledIntegrations.tsx deleted file mode 100644 index 7069900b68f..00000000000 --- a/apps/studio/components/interfaces/Integrations/Landing/InstalledIntegrations.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard' -import { useInstalledIntegrations } from './useInstalledIntegrations' -import AlertError from '@/components/ui/AlertError' - -export const InstalledIntegrations = () => { - const { installedIntegrations, error, isLoading, isSuccess, isError } = useInstalledIntegrations() - - return ( - <div className="px-4 md:px-10 py-6 flex flex-col gap-y-5"> - <h2>Installed integrations</h2> - <div className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3"> - {isLoading && - new Array(3) - .fill(0) - .map((_, idx) => <IntegrationLoadingCard key={`integration-loading-${idx}`} />)} - {isError && ( - <AlertError - className="xl:col-span-3 2xl:col-span-4" - subject="Failed to retrieve installed integrations" - error={error} - /> - )} - {isSuccess && ( - <> - {installedIntegrations.length === 0 ? ( - <div className="xl:col-span-3 2xl:col-span-4 w-full h-[110px] border rounded-sm flex items-center justify-center"> - {/* [Joshen] Not high priority imo - very low chance this state will be seen cause Vault is always installed */} - {/* Some placeholder image when no integrations are installed */} - <p className="text-sm text-foreground-light">No integrations installed yet</p> - </div> - ) : ( - installedIntegrations.map((i) => <IntegrationCard key={i.id} {...i} />) - )} - </> - )} - </div> - </div> - ) -} diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index e17ce5e617c..32359359859 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -69,7 +69,7 @@ export const useInstalledIntegrations = () => { connectionString: project?.connectionString, }) - const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') + const isHooksEnabled = schemas.some((schema) => schema.name === 'supabase_functions') const installedIntegrations = useMemo(() => { return allIntegrations diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx index 28a3f480a06..f11637ce362 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx @@ -1,8 +1,8 @@ import { ArrowUpRight, BookOpen, Gauge, Settings } from 'lucide-react' import { useRouter } from 'next/router' import { Button, cn } from 'ui' -import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' +import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { MarketplaceDetailBreadrumbs } from './MarketplaceDetailBreadcrumbs' import { MarketplaceDetailHero } from './MarketplaceDetailHero' @@ -175,7 +175,9 @@ export const MarketplaceDetail = () => { <CustomPageComponent /> </div> ) : ( - <CustomPageComponent /> + <div className="flex-1 min-h-0"> + <CustomPageComponent /> + </div> ) ) : null} </> diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailBreadcrumbs.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailBreadcrumbs.tsx index b836ac80db5..dd4f56a0613 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailBreadcrumbs.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailBreadcrumbs.tsx @@ -9,8 +9,8 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' import { PageBreadcrumbs, PageBreadcrumbsActions } from 'ui-patterns/PageBreadcrumbs' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' interface MarketplaceDetailBreadrumbsProps { isLoading?: boolean diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailHero.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailHero.tsx index c2c67c177d0..b167b0a0681 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailHero.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailHero.tsx @@ -9,7 +9,7 @@ import { PageHeaderNavigationTabs, PageHeaderSummary, PageHeaderTitle, -} from 'ui-patterns' +} from 'ui-patterns/PageHeader' import { getMarketplaceSource, MarketplaceSourceBadge } from './Marketplace.constants' import { IntegrationLogo } from '@/components/interfaces/Integrations/Integration/IntegrationLogo' diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHero.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHero.tsx deleted file mode 100644 index 33ec2b241d5..00000000000 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHero.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { ArrowRight, Pause, Play } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useRef, useState } from 'react' -import { Badge, Button, cn } from 'ui' - -import { IntegrationLogo } from '../Integration/IntegrationLogo' -import { - formatCategoryLabel, - getMarketplaceSource, - MarketplaceSourceBadge, -} from './Marketplace.constants' -import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' - -const ROTATION_INTERVAL_MS = 7000 -interface MarketplaceFeaturedHeroProps { - integrations: IntegrationDefinition[] - installedIds: string[] - categoryOptions: Array<{ slug: string; name: string }> -} - -export const MarketplaceFeaturedHero = ({ - integrations, - installedIds, - categoryOptions, -}: MarketplaceFeaturedHeroProps) => { - const { data: project } = useSelectedProjectQuery() - - const [activeIndex, setActiveIndex] = useState(0) - const [isPaused, setIsPaused] = useState(false) - const [progressKey, setProgressKey] = useState(0) - const hoveringRef = useRef(false) - const cardRef = useRef<HTMLDivElement>(null) - - useEffect(() => { - if (integrations.length <= 1) return - if (isPaused) return - - const interval = setInterval(() => { - if (hoveringRef.current) return - setActiveIndex((idx) => (idx + 1) % integrations.length) - }, ROTATION_INTERVAL_MS) - - return () => clearInterval(interval) - }, [integrations.length, isPaused]) - - useEffect(() => { - const node = cardRef.current - if (!node) return - const enter = () => { - hoveringRef.current = true - } - const leave = () => { - hoveringRef.current = false - } - node.addEventListener('pointerenter', enter) - node.addEventListener('pointerleave', leave) - return () => { - node.removeEventListener('pointerenter', enter) - node.removeEventListener('pointerleave', leave) - } - }, []) - - if (integrations.length === 0) return null - - const coverColSpan = integrations.length >= 6 ? 2 : 1 - - const active = integrations[Math.min(activeIndex, integrations.length - 1)] - const activeCategorySlug = active.categories?.[0] - const activeCategoryName = activeCategorySlug - ? formatCategoryLabel(activeCategorySlug, categoryOptions) - : null - const isActiveInstalled = installedIds.includes(active.id) - const source = getMarketplaceSource(active) - - const handleTabClick = (idx: number) => { - setActiveIndex(idx) - } - - return ( - <section> - <div className="mb-2 flex items-center justify-between"> - <div className="flex items-center gap-2"> - <h2 className="text-sm">Featured integrations</h2> - </div> - <Button - aria-label={isPaused ? 'Resume auto-rotation' : 'Pause auto-rotation'} - variant="default" - size="tiny" - className="px-1.5" - icon={isPaused ? <Play size={10} /> : <Pause size={10} />} - onClick={() => - setIsPaused((p) => { - // When resuming, reset the progress animation so it starts from - // zero and stays in sync with the fresh JS interval. - if (p) setProgressKey((k) => k + 1) - return !p - }) - } - /> - </div> - <div - ref={cardRef} - className="overflow-hidden rounded-lg border bg-surface-100" - style={ - { - '--cover-width': `calc(${coverColSpan * 100}% / ${integrations.length})`, - } as React.CSSProperties - } - > - <Link - href={`/project/${project?.ref}/integrations/${active.id}/overview`} - className="grid gap-0 @3xl:grid-cols-[var(--cover-width)_minmax(0,1fr)] hover:bg-selection/20 transition-colors" - > - <div className="relative hidden @3xl:block"> - <FeaturedCover integration={active} categoryLabel={activeCategoryName} /> - </div> - - <div className="flex flex-col gap-4 p-5 @3xl:p-6"> - <div className="flex items-start @lg:items-center justify-between gap-4"> - <div className="flex items-center gap-2"> - <div className="flex flex-wrap flex-col @lg:flex-row @lg:items-center gap-2"> - <div className="flex items-center gap-2"> - <IntegrationLogo integration={active} size="h-7 w-7" /> - <span className="text-lg font-medium text-foreground">{active.name}</span> - </div> - <div className="flex items-center gap-2"> - <MarketplaceSourceBadge source={source} /> - {active.status && <Badge variant="warning">{active.status}</Badge>} - </div> - </div> - </div> - {isActiveInstalled && ( - <Badge variant="success" className="mt-2 @lg:mt-0 px-1.5 py-0 text-[10px]"> - Installed - </Badge> - )} - </div> - - <div className="grow flex flex-col gap-2"> - <p className="text-base text-foreground-light">{active.description}</p> - </div> - - <div className="mt-1 flex items-center gap-4"> - <span className="inline-flex items-center gap-1.5 text-xs text-foreground"> - {isActiveInstalled ? 'Manage integration' : 'View integration'} - <ArrowRight size={12} /> - </span> - </div> - </div> - </Link> - - <div - className="grid border-t" - style={{ gridTemplateColumns: `repeat(${integrations.length}, minmax(0, 1fr))` }} - > - {integrations.map((integration, idx) => { - const isActive = idx === activeIndex - const slug = integration.categories?.[0] - const categoryLabel = slug ? formatCategoryLabel(slug, categoryOptions) : null - return ( - <button - key={integration.id} - type="button" - onClick={() => handleTabClick(idx)} - className={cn( - 'group relative flex items-center gap-2.5 border-r p-3 text-left transition-colors last:border-r-0', - isActive ? 'bg-surface-200' : 'bg-transparent hover:bg-surface-200/60' - )} - aria-pressed={isActive} - > - <IntegrationLogo - integration={integration} - size="h-7 w-7" - className="hidden sm:flex" - /> - <div className="min-w-0 flex-1"> - <div - className={cn( - 'truncate text-sm', - isActive ? 'text-foreground' : 'text-foreground-light' - )} - > - {integration.name} - </div> - {categoryLabel && ( - <div className="truncate text-xs text-foreground-lighter">{categoryLabel}</div> - )} - </div> - {isActive && ( - <span - key={`${activeIndex}-${progressKey}`} - aria-hidden - className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left animate-marketplace-featured-progress bg-brand" - style={{ - animationDuration: `${ROTATION_INTERVAL_MS}ms`, - animationPlayState: isPaused ? 'paused' : 'running', - }} - /> - )} - </button> - ) - })} - </div> - </div> - </section> - ) -} - -interface FeaturedCoverProps { - integration: IntegrationDefinition - categoryLabel: string | null -} - -const FeaturedCover = ({ integration, categoryLabel }: FeaturedCoverProps) => ( - <div - key={integration.id} - className="relative h-full min-h-[200px] w-full overflow-hidden bg-linear-to-br from-surface-200 via-surface-100 to-surface-300" - > - <div - aria-hidden - className="absolute inset-0 opacity-60" - style={{ - backgroundImage: - 'radial-gradient(circle at 1px 1px, hsl(var(--border-default)/0.6) 1px, transparent 0)', - backgroundSize: '14px 14px', - }} - /> - <div className="absolute inset-0 flex items-center justify-center"> - <div className="relative flex h-24 w-24 items-center justify-center overflow-hidden rounded-2xl border bg-white shadow-sm"> - {integration.icon({ className: 'h-16 w-16' })} - </div> - </div> - {categoryLabel && ( - <span className="absolute bottom-3 left-3 text-[10px] font-medium uppercase tracking-wider text-foreground-lighter"> - {categoryLabel} - </span> - )} - </div> -) diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx index 7a1059f1c68..db457a62656 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceIndex.tsx @@ -1,15 +1,15 @@ import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs' import { useMemo, useRef } from 'react' import { Button, Card, ShadowScrollArea, Table, TableBody, TableHeader } from 'ui' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' +import { PageContainer } from 'ui-patterns/PageContainer' import { - EmptyStatePresentational, - PageContainer, PageHeader, PageHeaderDescription, PageHeaderMeta, PageHeaderSummary, PageHeaderTitle, -} from 'ui-patterns' +} from 'ui-patterns/PageHeader' import { EXCLUDED_CATEGORY_SLUGS, diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceSidebar.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceSidebar.tsx index 5ffbcf011ba..0b3f9b06dde 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceSidebar.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceSidebar.tsx @@ -3,7 +3,7 @@ import { ArrowUpRight, BookOpen, LayoutGrid, PlusSquare } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { cn } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx index b544c7de7c4..eeeb99a4ad4 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { usePgPartmanStatus } from '../usePgPartmanStatus' import { EnableExtensionModal } from '@/components/interfaces/Database/Extensions/EnableExtensionModal' diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx index a78591eebad..8524e773ffc 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx @@ -1,6 +1,6 @@ import { UseFormReturn } from 'react-hook-form' import { Badge, FormControl, FormField, SheetSection, Switch } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateQueueForm } from './CreateQueueSheet.schema' diff --git a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx index 0a6d60ef649..26fc8374fc8 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx index 03419956f64..463829e8318 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx @@ -151,7 +151,7 @@ export const QueueTab = () => { > <Link passHref - href={`/project/${ref}/auth/policies?search=${queueTable?.id}&schema=pgmq`} + href={`/project/${ref}/database/policies?search=${queueTable?.id}&schema=pgmq`} > Add RLS policy </Link> @@ -176,7 +176,7 @@ export const QueueTab = () => { > <Link passHref - href={`/project/${ref}/auth/policies?search=${queueTable?.id}&schema=pgmq`} + href={`/project/${ref}/database/policies?search=${queueTable?.id}&schema=pgmq`} > Auth {queuePolicies.length > 1 ? 'policies' : 'policy'} </Link> diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx deleted file mode 100644 index b5aa5e781e4..00000000000 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import dayjs from 'dayjs' -import { includes, sortBy } from 'lodash' -import { Check, ChevronRight, Loader2, X } from 'lucide-react' -import { useRouter } from 'next/router' - -import { pgmqQueueTable } from './Queues.utils' -import Table from '@/components/to-be-cleaned/Table' -import { useQueuesMetricsQuery } from '@/data/database-queues/database-queues-metrics-query' -import { PostgresQueue } from '@/data/database-queues/database-queues-query' -import { useTablesQuery } from '@/data/tables/tables-query' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DATETIME_FORMAT } from '@/lib/constants' - -interface QueuesRowsProps { - queues: PostgresQueue[] - filterString: string -} - -const QueueRow = ({ queue }: { queue: PostgresQueue }) => { - const router = useRouter() - const { data: selectedProject } = useSelectedProjectQuery() - - const { data: queueTables } = useTablesQuery({ - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - schema: 'pgmq', - }) - const queueTable = queueTables?.find((x) => x.name === pgmqQueueTable(queue.queue_name)) - const isRlsEnabled = !!queueTable?.rls_enabled - - const { data: metrics, isPending: isLoading } = useQueuesMetricsQuery( - { - queueName: queue.queue_name, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - }, - { - staleTime: 30 * 1000, // 30 seconds, talk with Oli whether this is ok to call every minute - } - ) - - const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic' - - return ( - <Table.tr - key={queue.queue_name} - onClick={() => { - router.push( - `/project/${selectedProject?.ref}/integrations/queues/queues/${queue.queue_name}` - ) - }} - > - <Table.td className="truncate"> - <p title={queue.queue_name}>{queue.queue_name}</p> - </Table.td> - <Table.td className="table-cell overflow-auto"> - <p title={type.toLocaleLowerCase()} className="truncate"> - {type} - </p> - </Table.td> - <Table.td className="table-cell"> - <div className="flex justify-center"> - {isRlsEnabled ? <Check size={14} className="text-brand" /> : <X size={14} />} - </div> - </Table.td> - <Table.td className="table-cell"> - <p title={queue.created_at}>{dayjs(queue.created_at).format(DATETIME_FORMAT)}</p> - </Table.td> - <Table.td className="table-cell"> - <div className="flex justify-center"> - {isLoading ? ( - <Loader2 className="animate-spin" size={16} /> - ) : ( - <p> - {metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null} - </p> - )} - </div> - </Table.td> - <Table.td> - <div className="flex items-center justify-end"> - <ChevronRight size="18" /> - </div> - </Table.td> - </Table.tr> - ) -} - -export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsProps) => { - const filteredQueues = fetchedQueues.filter((x) => - includes(x.queue_name.toLowerCase(), filterString.toLowerCase()) - ) - const queues = sortBy(filteredQueues, (func) => func.queue_name.toLocaleLowerCase()) - - if (queues.length === 0 && filterString.length > 0) { - return ( - <Table.tr> - <Table.td colSpan={5}> - <p className="text-sm text-foreground">No results found</p> - <p className="text-sm text-foreground-light"> - Your search for "{filterString}" did not return any results - </p> - </Table.td> - </Table.tr> - ) - } - - return ( - <> - {queues.map((q) => ( - <QueueRow key={q.queue_name} queue={q} /> - ))} - </> - ) -} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx index 5739d29f786..3f99d8f060e 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Form, FormControl, FormField, FormItem, Switch } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx index 71c1cca143a..d66eb2ca948 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx @@ -10,7 +10,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { CreateQueueSheet } from './CreateQueueSheet' import { formatQueueColumns, prepareQueuesForDataGrid } from './Queues.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useQueuesQuery } from '@/data/database-queues/database-queues-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { onSearchInputEscape } from '@/lib/keyboard' diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx index 479fc99cc89..5782a9f59b6 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx @@ -7,7 +7,7 @@ import { Badge, Button, cn, ResizableHandle, ResizablePanel, ResizablePanelGroup import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { PostgresQueueMessage } from '@/data/database-queues/database-queue-messages-infinite-query' import type { ResponseError } from '@/types' diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx index c9bd7983cf8..2b16556511d 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -30,7 +30,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { pgmqArchiveTable, pgmqQueueTable } from '../Queues.utils' import { getQueueFunctionsMapping } from './Queue.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query' import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx index 97fa694f710..c8032d15140 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx @@ -21,7 +21,7 @@ import { AddNewSecretModal } from './AddNewSecretModal' import { DeleteSecretModal } from './DeleteSecretModal' import { EditSecretModal } from './EditSecretModal' import { formatSecretColumns } from './Secrets.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' import { useVaultSecretsQuery } from '@/data/vault/vault-secrets-query' diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx index 371c1e5d51e..454acf34276 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -1,14 +1,14 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { toast } from 'sonner' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useHooksEnableMutation } from '@/data/database/hooks-enable-mutation' import { useSchemasQuery } from '@/data/database/schemas-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationError.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationError.tsx index f768632485f..9bd3dfc2f56 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationError.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationError.tsx @@ -1,7 +1,7 @@ import { Button } from 'ui' import { useStripeSyncStatus } from './useStripeSyncStatus' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' export const InstallationError = ({ error, diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx index 4025d6bf64e..e05b84a39ea 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx @@ -17,7 +17,7 @@ import { SheetSection, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StatusDisplay.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StatusDisplay.tsx deleted file mode 100644 index 85bb055f2b5..00000000000 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StatusDisplay.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { SchemaInstallationStatus } from '@stripe/sync-engine/supabase' -import { AlertCircle, Check, RefreshCwIcon } from 'lucide-react' - -import { - hasInstallError, - hasUninstallError, - isInstalled, - isInstalling, - isUninstalling, -} from './stripe-sync-status' - -export const StatusDisplay = ({ - status, - isInstallRequested, - isInstallInitiated, - isUninstallRequested, - isUninstallInitiated, - isUpgrade, - timedOut, -}: { - status: SchemaInstallationStatus - isInstallRequested: boolean - isInstallInitiated: boolean - isUninstallRequested: boolean - isUninstallInitiated: boolean - isUpgrade?: boolean - timedOut: boolean -}) => { - const installed = isInstalled(status) - const installError = hasInstallError(status) - const uninstallError = hasUninstallError(status) - const installInProgress = isInstalling(status) - const uninstallInProgress = isUninstalling(status) - - const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut - const uninstalling = - (uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut - - if (uninstalling) { - return ( - <span className="flex items-center gap-2 text-foreground-light text-sm"> - <RefreshCwIcon size={14} className="animate-spin text-foreground-lighter" /> - Uninstalling... - </span> - ) - } - if (uninstallError) { - return ( - <span className="flex items-center gap-2 text-foreground-light text-sm"> - <AlertCircle size={14} className="text-destructive" /> - Uninstallation error - </span> - ) - } - if (installing) { - return ( - <span className="flex items-center gap-2 text-foreground-light text-sm"> - <RefreshCwIcon size={14} className="animate-spin text-foreground-lighter" /> - {isUpgrade ? 'Upgrading...' : 'Installing...'} - </span> - ) - } - if (installError) { - return ( - <span className="flex items-center gap-2 text-foreground-light text-sm"> - <AlertCircle size={14} className="text-destructive" /> - {isUpgrade ? 'Upgrade error' : 'Installation error'} - </span> - ) - } - if (installed) { - return ( - <span className="flex items-center gap-2 text-foreground-light text-sm"> - <Check size={14} strokeWidth={1.5} className="text-brand" /> Installed - </span> - ) - } - return ( - <span className="flex items-center gap-2 text-foreground-light text-sm">Not installed</span> - ) -} diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx index 7d07b9828d1..69d538bec14 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx @@ -3,7 +3,7 @@ import { formatRelative } from 'date-fns' import { BadgeCheck, RefreshCwIcon } from 'lucide-react' import Link from 'next/link' import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui' -import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { PageSection, PageSectionContent, @@ -12,6 +12,8 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { isInstalled, isSyncRunning, isUninstalling } from './stripe-sync-status' import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold' diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx index 6dcdc729f04..2f189271575 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx @@ -22,7 +22,7 @@ import { TableCell, TableRow, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { AlgorithmHoverCard } from '../algorithm-hover-card' import { statusColors, statusLabels } from '../jwt.constants' diff --git a/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx b/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx index 83cea333e69..5d9ccfb4690 100644 --- a/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx +++ b/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx @@ -4,7 +4,7 @@ import { EyeOff, Lock } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { Badge, Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index a7779893e6e..a9b00f7fda9 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -43,7 +43,7 @@ export const lintInfoMap: LintInfo[] = [ name: 'auth_rls_initplan', title: 'Auth RLS Initialization Plan', icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />, - link: ({ projectRef }) => `/project/${projectRef}/auth/policies`, + link: ({ projectRef }) => `/project/${projectRef}/database/policies`, linkText: 'View policies', docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0003_auth_rls_initplan`, category: 'performance', @@ -72,7 +72,7 @@ export const lintInfoMap: LintInfo[] = [ title: 'Multiple Permissive Policies', icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />, link: ({ projectRef, metadata }) => - `/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, + `/project/${projectRef}/database/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, linkText: 'View policies', docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0006_multiple_permissive_policies`, category: 'performance', @@ -82,7 +82,7 @@ export const lintInfoMap: LintInfo[] = [ title: 'Policy Exists RLS Disabled', icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />, link: ({ projectRef, metadata }) => - `/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, + `/project/${projectRef}/database/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, linkText: 'View policies', docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0007_policy_exists_rls_disabled`, category: 'security', @@ -92,7 +92,7 @@ export const lintInfoMap: LintInfo[] = [ title: 'RLS Enabled No Policy', icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />, link: ({ projectRef, metadata }) => - `/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, + `/project/${projectRef}/database/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, linkText: 'View table', docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0008_rls_enabled_no_policy`, category: 'security', @@ -141,7 +141,7 @@ export const lintInfoMap: LintInfo[] = [ title: 'RLS Disabled in Public', icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />, link: ({ projectRef, metadata }) => - `/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, + `/project/${projectRef}/database/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, linkText: 'View policies', docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0013_rls_disabled_in_public`, category: 'security', @@ -187,7 +187,7 @@ export const lintInfoMap: LintInfo[] = [ name: 'rls_references_user_metadata', title: 'RLS references user metadata', icon: <User className="text-foreground-muted" size={15} strokeWidth={1} />, - link: ({ projectRef }) => `/project/${projectRef}/auth/policies`, + link: ({ projectRef }) => `/project/${projectRef}/database/policies`, linkText: 'View policies', docsLink: `${DOCS_URL}/guides/database/database-linter?queryGroups=lint&lint=0015_rls_references_user_metadata`, category: 'security', @@ -325,7 +325,7 @@ export const lintInfoMap: LintInfo[] = [ title: 'RLS Policy Always True', icon: <Table2 className="text-foreground-muted" size={15} strokeWidth={1} />, link: ({ projectRef, metadata }) => - `/project/${projectRef}/auth/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, + `/project/${projectRef}/database/policies?schema=${encodeURIComponent(metadata?.schema ?? '')}&search=${encodeURIComponent(metadata?.name ?? '')}`, linkText: 'View policies', docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0024_permissive_rls_policy`, category: 'security', diff --git a/apps/studio/components/interfaces/LocalDropdown.tsx b/apps/studio/components/interfaces/LocalDropdown.tsx index cb0b976e1f2..ea78833af65 100644 --- a/apps/studio/components/interfaces/LocalDropdown.tsx +++ b/apps/studio/components/interfaces/LocalDropdown.tsx @@ -14,7 +14,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, singleThemes, - Theme, } from 'ui' import { ButtonTooltip } from '../ui/ButtonTooltip' @@ -83,7 +82,7 @@ export const LocalDropdown = ({ setTheme(value) }} > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( <DropdownMenuRadioItem key={theme.value} value={theme.value} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index 162e5bc1909..4bbcfbdd78a 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { IS_PLATFORM, useFlag } from 'common' +import { IS_PLATFORM } from 'common' import Link from 'next/link' import { ReactNode, useEffect, useMemo, useRef } from 'react' import { useForm } from 'react-hook-form' @@ -51,6 +51,7 @@ import { logDrainHeaderEntriesSchema, type LogDrainHeaderRow, } from './LogDrains.utils' +import { useEnabledLogDrainTypes } from './useEnabledLogDrainTypes' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { Shortcut } from '@/components/ui/Shortcut' import { LogDrainData } from '@/data/log-drains/log-drains-query' @@ -347,12 +348,7 @@ export function LogDrainDestinationSheetForm({ ) }, [defaultValues, mode]) - const sentryEnabled = useFlag('SentryLogDrain') - const s3Enabled = useFlag('S3logdrain') - const axiomEnabled = useFlag('axiomLogDrain') - const otlpEnabled = useFlag('otlpLogDrain') - const last9Enabled = useFlag('Last9LogDrain') - const syslogEnabled = useFlag('syslogLogDrain') + const enabledLogDrainTypes = useEnabledLogDrainTypes() const formRef = useRef<HTMLFormElement>(null) @@ -474,15 +470,7 @@ export function LogDrainDestinationSheetForm({ {LOG_DRAIN_TYPES.find((t) => t.value === type)?.name} </SelectTrigger> <SelectContent> - {LOG_DRAIN_TYPES.filter((t) => { - if (t.value === 'sentry') return sentryEnabled - if (t.value === 's3') return s3Enabled - if (t.value === 'axiom') return axiomEnabled - if (t.value === 'otlp') return otlpEnabled - if (t.value === 'last9') return last9Enabled - if (t.value === 'syslog') return syslogEnabled - return true - }).map((type) => ( + {enabledLogDrainTypes.map((type) => ( <SelectItem value={type.value} key={type.value} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainsList.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainsList.tsx index f2403c07f57..825d281bd83 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainsList.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainsList.tsx @@ -23,7 +23,7 @@ import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants' import { LogDrainsCard } from './LogDrainsCard' import { useEnabledLogDrainTypes } from './useEnabledLogDrainTypes' import { VoteLink } from './VoteLink' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { LogDrainData } from '@/data/log-drains/log-drains-query' import type { ResponseError } from '@/types' diff --git a/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx b/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx index 31f28558b28..aa728dc7936 100644 --- a/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx @@ -11,8 +11,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { LogDrainDestinationSheetForm } from './LogDrainDestinationSheetForm' import { LogDrainType } from './LogDrains.constants' diff --git a/apps/studio/components/interfaces/LogDrains/useEnabledLogDrainTypes.ts b/apps/studio/components/interfaces/LogDrains/useEnabledLogDrainTypes.ts index a722f15187c..d33c2c664f0 100644 --- a/apps/studio/components/interfaces/LogDrains/useEnabledLogDrainTypes.ts +++ b/apps/studio/components/interfaces/LogDrains/useEnabledLogDrainTypes.ts @@ -3,19 +3,9 @@ import { useFlag } from 'common' import { LOG_DRAIN_TYPES } from './LogDrains.constants' export function useEnabledLogDrainTypes() { - const sentryEnabled = useFlag('SentryLogDrain') - const s3Enabled = useFlag('S3logdrain') - const axiomEnabled = useFlag('axiomLogDrain') - const otlpEnabled = useFlag('otlpLogDrain') - const last9Enabled = useFlag('Last9LogDrain') const syslogEnabled = useFlag('syslogLogDrain') return LOG_DRAIN_TYPES.filter((t) => { - if (t.value === 'sentry') return sentryEnabled - if (t.value === 's3') return s3Enabled - if (t.value === 'axiom') return axiomEnabled - if (t.value === 'otlp') return otlpEnabled - if (t.value === 'last9') return last9Enabled if (t.value === 'syslog') return syslogEnabled return true }) diff --git a/apps/studio/components/interfaces/Observability/Observability.constants.ts b/apps/studio/components/interfaces/Observability/Observability.constants.ts new file mode 100644 index 00000000000..2c47e519e38 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/Observability.constants.ts @@ -0,0 +1,15 @@ +import { DOCS_URL } from '@/lib/constants' + +export const OBSERVABILITY_DOCS_HREFS = { + overview: `${DOCS_URL}/guides/telemetry/reports`, + queryPerformance: `${DOCS_URL}/guides/platform/performance#examining-query-performance`, + queryInsights: `${DOCS_URL}/guides/platform/performance#examining-query-performance`, + apiGateway: `${DOCS_URL}/guides/telemetry/reports#api-gateway`, + database: `${DOCS_URL}/guides/telemetry/reports#database`, + dataApi: `${DOCS_URL}/guides/telemetry/reports#postgrest`, + auth: `${DOCS_URL}/guides/telemetry/reports#auth`, + edgeFunctions: `${DOCS_URL}/guides/telemetry/reports#edge-functions`, + storage: `${DOCS_URL}/guides/telemetry/reports#storage`, + realtime: `${DOCS_URL}/guides/realtime/reports`, + customReport: `${DOCS_URL}/guides/telemetry/reports#using-reports`, +} as const diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx index 7b3eef9ab6a..393734b706c 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -6,13 +6,20 @@ import { useRouter } from 'next/router' import { useCallback, useMemo, useState } from 'react' import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { useUnifiedLogsPreview } from '../App/FeaturePreview/FeaturePreviewContext' import { DatabaseInfrastructureSection } from './DatabaseInfrastructureSection' +import { OBSERVABILITY_DOCS_HREFS } from './Observability.constants' import { useObservabilityOverviewData } from './ObservabilityOverview.utils' import { ObservabilityOverviewFooter } from './ObservabilityOverviewFooter' import { ServiceHealthTable } from './ServiceHealthTable' import { useSlowQueriesCount } from './useSlowQueriesCount' import ReportHeader from '@/components/interfaces/Reports/ReportHeader' import ReportPadding from '@/components/interfaces/Reports/ReportPadding' +import { + buildUnifiedLogsUrl, + type UnifiedLogType, +} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DocsButton } from '@/components/ui/DocsButton' import { ChartIntervalDropdown } from '@/components/ui/Logs/ChartIntervalDropdown' import { CHART_INTERVALS } from '@/components/ui/Logs/logs.utils' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' @@ -22,6 +29,8 @@ import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganizati import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' +const REPORT_TITLE = 'Overview' + type ChartIntervalKey = '1hr' | '1day' | '7day' export const ObservabilityOverview = () => { @@ -30,6 +39,7 @@ export const ObservabilityOverview = () => { const { data: organization } = useSelectedOrganizationQuery() const queryClient = useQueryClient() + const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const { projectStorageAll: storageSupported } = useIsFeatureEnabled(['project_storage:all']) const { isEnabled: isDataApiEnabled } = useIsDataApiEnabled({ projectRef }) @@ -65,13 +75,22 @@ export const ObservabilityOverview = () => { setShowIntervalDropdown((open) => !open) }) + const getLogsUrl = useCallback( + (logType: UnifiedLogType, legacyLogsUrl: string) => + isUnifiedLogsEnabled + ? buildUnifiedLogsUrl({ projectRef: projectRef!, logType }) + : `/project/${projectRef}${legacyLogsUrl}`, + [projectRef, isUnifiedLogsEnabled] + ) + const serviceBase = useMemo( () => [ { key: 'data_api' as const, name: 'API Gateway', reportUrl: undefined, - logsUrl: `/project/${projectRef}/logs/edge-logs`, + logType: 'edge' as const, + logsUrl: getLogsUrl('edge', '/logs/edge-logs'), enabled: isDataApiEnabled, hasReport: false, }, @@ -79,7 +98,8 @@ export const ObservabilityOverview = () => { key: 'db' as const, name: 'Database', reportUrl: `/project/${projectRef}/observability/database`, - logsUrl: `/project/${projectRef}/logs/postgres-logs`, + logType: 'postgres' as const, + logsUrl: getLogsUrl('postgres', '/logs/postgres-logs'), enabled: true, hasReport: true, }, @@ -87,7 +107,8 @@ export const ObservabilityOverview = () => { key: 'postgrest' as const, name: 'PostgREST', reportUrl: `/project/${projectRef}/observability/postgrest`, - logsUrl: `/project/${projectRef}/logs/postgrest-logs`, + logType: 'postgrest' as const, + logsUrl: getLogsUrl('postgrest', '/logs/postgrest-logs'), enabled: true, hasReport: true, }, @@ -95,7 +116,8 @@ export const ObservabilityOverview = () => { key: 'auth' as const, name: 'Auth', reportUrl: `/project/${projectRef}/observability/auth`, - logsUrl: `/project/${projectRef}/logs/auth-logs`, + logType: 'auth' as const, + logsUrl: getLogsUrl('auth', '/logs/auth-logs'), enabled: true, hasReport: true, }, @@ -103,7 +125,8 @@ export const ObservabilityOverview = () => { key: 'functions' as const, name: 'Edge Functions', reportUrl: `/project/${projectRef}/observability/edge-functions`, - logsUrl: `/project/${projectRef}/logs/edge-functions-logs`, + logType: 'edge function' as const, + logsUrl: getLogsUrl('edge function', '/logs/edge-functions-logs'), enabled: true, hasReport: true, }, @@ -111,7 +134,8 @@ export const ObservabilityOverview = () => { key: 'storage' as const, name: 'Storage', reportUrl: `/project/${projectRef}/observability/storage`, - logsUrl: `/project/${projectRef}/logs/storage-logs`, + logType: 'storage' as const, + logsUrl: getLogsUrl('storage', '/logs/storage-logs'), enabled: storageSupported, hasReport: true, }, @@ -119,12 +143,13 @@ export const ObservabilityOverview = () => { key: 'realtime' as const, name: 'Realtime', reportUrl: `/project/${projectRef}/observability/realtime`, - logsUrl: `/project/${projectRef}/logs/realtime-logs`, + logType: 'realtime' as const, + logsUrl: getLogsUrl('realtime', '/logs/realtime-logs'), enabled: true, hasReport: true, }, ], - [projectRef, storageSupported, isDataApiEnabled] + [projectRef, storageSupported, isDataApiEnabled, getLogsUrl] ) const enabledServices = serviceBase.filter((s) => s.enabled) @@ -133,7 +158,7 @@ export const ObservabilityOverview = () => { // Navigate to the log view scoped to the clicked bar's bucket window const handleBarClick = useCallback( - (logsUrl: string) => (datum: any) => { + (service: { logType: UnifiedLogType; logsUrl: string }) => (datum: any) => { if (!datum?.timestamp) return // datum.timestamp is already the UTC-truncated bucket boundary from timestamp_trunc(), @@ -142,17 +167,24 @@ export const ObservabilityOverview = () => { const start = datum.timestamp const end = dayjs.utc(datum.timestamp).add(1, unit).toISOString() - const queryParams = new URLSearchParams({ its: start, ite: end }) - router.push(`${logsUrl}?${queryParams.toString()}`) + if (isUnifiedLogsEnabled) { + router.push( + buildUnifiedLogsUrl({ projectRef: projectRef!, logType: service.logType, start, end }) + ) + } else { + const queryParams = new URLSearchParams({ its: start, ite: end }) + const separator = service.logsUrl.includes('?') ? '&' : '?' + router.push(`${service.logsUrl}${separator}${queryParams.toString()}`) + } }, - [router, interval] + [router, interval, isUnifiedLogsEnabled, projectRef] ) return ( <ReportPadding> <div className="flex flex-row justify-between items-center"> <div className="flex items-center gap-3"> - <ReportHeader title="Overview" /> + <ReportHeader title={REPORT_TITLE} /> <Tooltip> <TooltipTrigger asChild> <Badge variant="warning">Beta</Badge> @@ -163,6 +195,7 @@ export const ObservabilityOverview = () => { </Tooltip> </div> <div className="flex items-center gap-2"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.overview} topic={REPORT_TITLE} /> <ShortcutTooltip shortcutId={SHORTCUT_IDS.OBSERVABILITY_REFRESH} label="Refresh report" @@ -200,6 +233,7 @@ export const ObservabilityOverview = () => { name: service.name, description: '', reportUrl: service.hasReport ? service.reportUrl : undefined, + logType: service.logType, logsUrl: service.logsUrl, }))} serviceData={overviewData.services} diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx deleted file mode 100644 index d8faea781b4..00000000000 --- a/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import Link from 'next/link' -import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, cn, Loading } from 'ui' -import { LogsBarChart } from 'ui-patterns/LogsBarChart' - -import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' -import { getHealthStatus, type ServiceKey } from './ObservabilityOverview.utils' -import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' - -const colorClassMap: Record<string, string> = { - muted: 'bg-muted', - destructive: 'bg-destructive', - warning: 'bg-warning', - brand: 'bg-brand', -} - -export type ServiceHealthCardProps = { - serviceName: string - serviceKey: ServiceKey - total: number - errorRate: number - errorCount: number - warningCount: number - chartData: LogsBarChartDatum[] - reportUrl?: string // undefined if feature flag disabled or no report available - logsUrl: string - isLoading: boolean - onBarClick: (datum: any) => void - datetimeFormat: string -} - -export const ServiceHealthCard = ({ - serviceName, - serviceKey: _serviceKey, - total, - errorRate, - errorCount, - warningCount, - chartData, - reportUrl, - logsUrl, - isLoading, - onBarClick, - datetimeFormat, -}: ServiceHealthCardProps) => { - const { color } = getHealthStatus(errorRate, total) - - return ( - <Card className="mb-0 md:mb-0 h-full flex flex-col"> - <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-0 border-b-0"> - <div className="flex flex-col"> - <CardTitle className="text-foreground-light flex items-center gap-2 text-xs font-medium"> - <div className={cn('w-1.5 h-1.5 rounded-full', colorClassMap[color] || 'bg-muted')} /> - {serviceName} - </CardTitle> - <div className="flex items-start gap-6 mt-2"> - <div className="flex flex-col"> - <span className="text-foreground text-xl">{total.toLocaleString()}</span> - <span className="text-foreground-light text-xs">requests</span> - </div> - <div className="flex flex-col"> - <span className="text-foreground text-xl">{errorRate.toFixed(1)}%</span> - <span className="text-foreground-light text-xs">error rate</span> - </div> - </div> - </div> - <div className="flex items-end gap-4 text-foreground-light"> - <div className="flex flex-col items-end"> - <div className="flex items-center gap-2"> - <div className="w-1.5 h-1.5 bg-warning rounded-full" /> - <span className="heading-meta">Warn</span> - </div> - <span className="text-foreground text-xl">{warningCount.toLocaleString()}</span> - </div> - <div className="flex flex-col items-end"> - <div className="flex items-center gap-2"> - <div className="w-1.5 h-1.5 bg-destructive rounded-full" /> - <span className="heading-meta">Err</span> - </div> - <span className="text-foreground text-xl">{errorCount.toLocaleString()}</span> - </div> - </div> - </CardHeader> - - <CardContent className="p-card pt-4 flex-1 overflow-hidden max-h-[200px]"> - <Loading isFullHeight active={isLoading}> - <LogsBarChart - isFullHeight - data={chartData} - DateTimeFormat={datetimeFormat} - onBarClick={onBarClick} - EmptyState={ - <NoDataPlaceholder size="small" message="No data for selected period" isFullHeight /> - } - /> - </Loading> - </CardContent> - - <CardFooter className="border-t pt-3 pb-4 px-card flex gap-2"> - {reportUrl && ( - <Button variant="default" size="tiny" asChild className="flex-1"> - <Link href={reportUrl}>View Report</Link> - </Button> - )} - <Button variant="default" size="tiny" asChild className="flex-1"> - <Link href={logsUrl}>View Logs</Link> - </Button> - </CardFooter> - </Card> - ) -} diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx index 7cc32dac2a0..a8f567a8d70 100644 --- a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx +++ b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx @@ -9,11 +9,13 @@ import { Tooltip, TooltipContent, TooltipTrigger, + type ChartConfig, } from 'ui' import { ChartEmptyState, ChartLoadingState } from 'ui-patterns/Chart' import { LogsBarChart } from 'ui-patterns/LogsBarChart' import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' +import type { UnifiedLogType } from '../UnifiedLogs/UnifiedLogs.utils' import { getHealthStatus, type ServiceKey } from './ObservabilityOverview.utils' type ServiceConfig = { @@ -21,6 +23,7 @@ type ServiceConfig = { name: string description: string reportUrl?: string + logType: UnifiedLogType logsUrl: string } @@ -36,7 +39,7 @@ type ServiceData = { export type ServiceHealthTableProps = { services: ServiceConfig[] serviceData: Record<string, ServiceData> - onBarClick: (logsUrl: string) => (datum: LogsBarChartDatum) => void + onBarClick: (service: ServiceConfig) => (datum: LogsBarChartDatum) => void datetimeFormat: string } @@ -47,6 +50,12 @@ const colorClassMap: Record<string, string> = { brand: 'bg-brand', } +const LEVEL_CHART_CONFIG: ChartConfig = { + error_count: { label: 'Errors' }, + warning_count: { label: 'Warnings' }, + ok_count: { label: 'Infos' }, +} + const SERVICE_DESCRIPTIONS: Record<ServiceKey, string> = { db: 'PostgreSQL database health and performance', auth: 'Authentication and user management', @@ -171,6 +180,7 @@ const ServiceCell = ({ hideDateRange hideXAxis data={data.eventChartData} + chartConfig={LEVEL_CHART_CONFIG} DateTimeFormat={datetimeFormat} onBarClick={onBarClick} EmptyState={<ChartEmptyState className="h-full" description="No traffic" />} @@ -208,7 +218,7 @@ export const ServiceHealthTable = ({ key={service.key} service={service} data={data} - onBarClick={onBarClick(service.logsUrl)} + onBarClick={onBarClick(service)} datetimeFormat={datetimeFormat} className={cn( 'border-default border-b', diff --git a/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts b/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts deleted file mode 100644 index 99bbffca30b..00000000000 --- a/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' -import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' -import { safeSql, type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' - -type PostgrestMetricsVariables = { - projectRef: string - startDate: string - endDate: string - interval: '1hr' | '1day' | '7day' -} - -function getIntervalTrunc(interval: '1hr' | '1day' | '7day'): SafeLogSqlFragment { - switch (interval) { - case '1hr': - return safeSql`minute` // 1-minute buckets for 1 hour - case '1day': - return safeSql`hour` // 1-hour buckets for 1 day - case '7day': - return safeSql`day` // 1-day buckets for 7 days - default: - return safeSql`hour` - } -} - -const POSTGREST_METRICS_SQL = (interval: '1hr' | '1day' | '7day'): SafeLogSqlFragment => { - const trunc = getIntervalTrunc(interval) - - return safeSql` - -- postgrest-overview-metrics - select - cast(timestamp_trunc(t.timestamp, ${trunc}) as datetime) as timestamp, - countif(response.status_code < 300) as ok_count, - countif(response.status_code >= 300 and response.status_code < 400) as warning_count, - countif(response.status_code >= 400) as error_count - FROM edge_logs t - cross join unnest(metadata) as m - cross join unnest(m.response) as response - cross join unnest(m.request) as request - WHERE - request.path like '/rest/%' - GROUP BY - timestamp - ORDER BY - timestamp ASC - ` -} - -type MetricsRow = { - timestamp: string - ok_count: number - warning_count: number - error_count: number -} - -async function fetchPostgrestMetrics( - { projectRef, startDate, endDate, interval }: PostgrestMetricsVariables, - signal?: AbortSignal -) { - const sql = POSTGREST_METRICS_SQL(interval) - - const data = await executeAnalyticsSql({ - projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', - sql, - iso_timestamp_start: startDate, - iso_timestamp_end: endDate, - method: 'get', - signal, - }) - - if (data?.error) throw data.error - - return (data?.result || []) as MetricsRow[] -} - -export const usePostgrestOverviewMetrics = ( - { projectRef, startDate, endDate, interval }: PostgrestMetricsVariables, - options?: { enabled?: boolean } -) => { - return useQuery({ - queryKey: ['postgrest-overview-metrics', projectRef, startDate, endDate, interval], - queryFn: ({ signal }) => - fetchPostgrestMetrics({ projectRef, startDate, endDate, interval }, signal), - enabled: (options?.enabled ?? true) && Boolean(projectRef), - staleTime: 1000 * 60, - }) -} - -export const transformPostgrestMetrics = (rows: MetricsRow[]): LogsBarChartDatum[] => { - return rows.map((row) => ({ - timestamp: row.timestamp, - ok_count: row.ok_count, - warning_count: row.warning_count, - error_count: row.error_count, - })) -} diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index 6904b844ebf..f104b5ed191 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -14,10 +14,10 @@ import { LogDetailsPanel } from '@/components/interfaces/AuditLogs/LogDetailsPan import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import Table from '@/components/to-be-cleaned/Table' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { FilterPopover } from '@/components/ui/FilterPopover' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' import { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx index cf3b15f16db..30e14740e44 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx @@ -9,10 +9,10 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' -import SparkBar from '@/components/ui/SparkBar' +import { NoPermission } from '@/components/ui/NoPermission' +import { SparkBar } from '@/components/ui/SparkBar' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index bd4240b4265..06a5a46dbe6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -4,7 +4,7 @@ import { InfoTooltip } from 'ui-patterns/info-tooltip' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { billingMetricUnit, formatUsage } from '../helpers' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { PricingMetric } from '@/data/analytics/org-daily-stats-query' import { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 7d4daf6c5e5..98c584f1744 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -20,8 +20,8 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import PartnerManagedResource from '@/components/ui/PartnerManagedResource' import { organizationKeys } from '@/data/organizations/keys' import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts index 17ea604c872..d552eff33b3 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts @@ -166,6 +166,7 @@ export function useBillingCustomerDataForm({ const taxIdValues = form.getValues() const selectedTaxId = TAX_IDS.find((option) => option.name === taxIdValues.tax_id_name) + const taxIdValue = taxIdValues.tax_id_value?.trim() ?? '' const payload = { address: { @@ -178,11 +179,11 @@ export function useBillingCustomerDataForm({ }, billing_name: address.name.trim(), tax_id: - selectedTaxId && taxIdValues.tax_id_type?.length && taxIdValues.tax_id_value?.length + selectedTaxId && taxIdValues.tax_id_type?.length && taxIdValue.length ? { type: taxIdValues.tax_id_type, value: sanitizeTaxIdValue({ - value: taxIdValues.tax_id_value, + value: taxIdValue, name: taxIdValues.tax_id_name, }), country: getEffectiveTaxCountry(selectedTaxId), diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index c3052aafdc3..31a0e5566f5 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -24,7 +24,7 @@ import { import { FormActions } from '@/components/ui/Forms/FormActions' import { FormPanel } from '@/components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from '@/components/ui/Forms/FormSection' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query' import { useOrganizationUpdateMutation } from '@/data/organizations/organization-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index f39d4cf1176..0c095da8531 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -15,9 +15,9 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import PartnerIcon from '@/components/ui/PartnerIcon' import { PARTNER_TO_NAME } from '@/components/ui/PartnerManagedResource' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx index 155aa028818..051c7930173 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx @@ -9,10 +9,10 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { FormPanel } from '@/components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from '@/components/ui/Forms/FormSection' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useOrgBalanceQuery } from '@/data/subscriptions/org-balance-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx index 13752a01f00..95c44733543 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx @@ -22,8 +22,10 @@ import { Input, Separator, } from 'ui' -import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { z } from 'zod' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/CancellationFlow.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/CancellationFlow.tsx index b64817dc36d..c1b4288de28 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/CancellationFlow.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/CancellationFlow.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import React, { ComponentProps, useEffect, useMemo, useState } from 'react' +import { ComponentProps, useEffect, useMemo, useState } from 'react' import { DowngradeModal } from './DowngradeModal' import { ExitSurveyModal } from './ExitSurveyModal' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx index 60b5714cf9a..76a9ccbfbd3 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx @@ -10,7 +10,7 @@ import { DialogSectionSeparator, DialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { isBeforeFreeTierTemplateBlockCutoff } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils' import { getComputeSize, OrgProject } from '@/data/projects/org-projects-infinite-query' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/InvoiceEstimateTooltip.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/InvoiceEstimateTooltip.tsx index e710b2922d1..17745d5bb46 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/InvoiceEstimateTooltip.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/InvoiceEstimateTooltip.tsx @@ -13,7 +13,7 @@ import { import { InfoTooltip } from 'ui-patterns/info-tooltip' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview' import { DOCS_URL } from '@/lib/constants' import { formatCurrency } from '@/lib/helpers' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index b7dadc4f15d..811ea8e7061 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -14,8 +14,8 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 088149e39fa..c264ddc1371 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -8,7 +8,7 @@ import { useMemo, useRef, useState } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { toast } from 'sonner' import { Button, cn, Dialog, DialogContent } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx index 0923335ca90..97d226169d9 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from 'react' import { Card, CardContent } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { CloudMarketplaceContractLinkingIneligibilityReason, diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx index 4392359d4fe..ff6449a20c6 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx @@ -2,7 +2,8 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { AwsMarketplaceInterstitial, diff --git a/apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx b/apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx index 243d683987e..fc2ee211067 100644 --- a/apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx @@ -11,7 +11,7 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { getDocument } from '@/data/documents/document-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' diff --git a/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx b/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx index 488d71de731..ca7c554ffb1 100644 --- a/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx @@ -11,7 +11,7 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { getDocument } from '@/data/documents/document-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' diff --git a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx index aa385898696..88a6930fa21 100644 --- a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx @@ -10,7 +10,7 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { getDocument } from '@/data/documents/document-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx index e2deae29c47..fb4379b66ac 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx @@ -1,4 +1,4 @@ -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { DeleteOrganizationButton } from './DeleteOrganizationButton' import PartnerManagedResource from '@/components/ui/PartnerManagedResource' diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx index 835015c428e..dfc6aae960a 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -158,5 +158,3 @@ ${ENV_VAR_RAW_KEYS.map((x) => { </SidePanel> ) } - -export default SidePanelVercelProjectLinker diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx index ec5ad3c66a4..4bbc2d7c48c 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx @@ -6,7 +6,7 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' export const InvoicesSection = () => { diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 42a058dfeed..b25978116dd 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -19,7 +19,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import InvoicePayButton from './InvoicePayButton' import { InvoiceStatus } from '@/components/interfaces/Billing/Invoices.types' import InvoiceStatusBadge from '@/components/interfaces/Billing/InvoiceStatusBadge' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import PartnerManagedResource from '@/components/ui/PartnerManagedResource' import { getInvoice } from '@/data/invoices/invoice-query' diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index a7c147df6d1..8deba853795 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -58,6 +58,13 @@ import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganizat import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from '@/lib/constants' import { validateReturnTo } from '@/lib/gotrue' import { useProfile } from '@/lib/profile' +import { + classifyApiError, + classifyStripeError, + classifyValidationError, +} from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' interface NewOrgFormProps { onPaymentMethodReset: () => void @@ -92,6 +99,7 @@ export const NewOrgForm = ({ }: NewOrgFormProps) => { const router = useRouter() const user = useProfile() + const track = useTrack() const { resolvedTheme } = useTheme() const { lastVisitedOrganization } = useLastVisitedOrganization() @@ -164,6 +172,14 @@ export const NewOrgForm = ({ } }, [isSuccess, form, organizations?.length, user.profile?.username, user.isSuccess]) + const hasTrackedFormExposed = useRef(false) + useEffect(() => { + if (hasTrackedFormExposed.current) return + if (!user.isSuccess) return + hasTrackedFormExposed.current = true + track('organization_creation_form_exposed') + }, [user.isSuccess, track]) + const [latestAddress, setLatestAddress] = useState<CustomerAddress>() const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>() @@ -226,6 +242,8 @@ export const NewOrgForm = ({ [freeOrgs, projectsByOrg] ) + const trackFunnelError = useTrackFunnelError() + const { mutate: createOrganization } = useOrganizationCreateMutation({ onSuccess: async (org) => { if ('pending_payment_intent_secret' in org && org.pending_payment_intent_secret) { @@ -236,6 +254,7 @@ export const NewOrgForm = ({ }, onError: (data) => { toast.error(data.message, { duration: 10_000 }) + trackFunnelError('org_creation', classifyApiError('org_creation', data), 'toast') setNewOrgLoading(false) }, }) @@ -246,6 +265,10 @@ export const NewOrgForm = ({ onOrganizationCreated({ slug: data.slug }) } }, + onError: (error) => { + toast.error(error.message, { dismissible: true, duration: 10_000 }) + trackFunnelError('org_creation', classifyApiError('org_creation', error), 'toast') + }, }) const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => { @@ -260,6 +283,11 @@ export const NewOrgForm = ({ size: form.getValues('size'), }) } else { + trackFunnelError( + 'org_creation', + classifyStripeError(paymentIntentConfirmation.error), + 'toast' + ) // If the payment intent is not successful, we reset the payment method and show an error toast.error(`Could not confirm payment. Please try again or use a different card.`, { duration: 10_000, @@ -270,6 +298,14 @@ export const NewOrgForm = ({ } const onOrganizationCreated = (org: { slug: string }) => { + if (submittedTier.current) { + track( + 'organization_creation_completed', + { tier: submittedTier.current }, + { organization: org.slug } + ) + } + const prefilledProjectName = user.profile?.username ? user.profile.username + `'s Project` : 'My Project' @@ -296,6 +332,8 @@ export const NewOrgForm = ({ } as StripeElementsOptions }, [paymentIntentSecret, resolvedTheme]) + const submittedTier = useRef<'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' | null>(null) + async function createOrg( formValues: z.infer<typeof formSchema>, paymentMethodId?: string, @@ -306,15 +344,17 @@ export const NewOrgForm = ({ } ) { const dbTier = formValues.plan === 'PRO' && !formValues.spend_cap ? 'PAYG' : formValues.plan + const tier = ('tier_' + dbTier.toLowerCase()) as + | 'tier_payg' + | 'tier_pro' + | 'tier_free' + | 'tier_team' + submittedTier.current = tier createOrganization({ name: formValues.name, kind: formValues.kind, - tier: ('tier_' + dbTier.toLowerCase()) as - | 'tier_payg' - | 'tier_pro' - | 'tier_free' - | 'tier_team', + tier, ...(formValues.kind == 'COMPANY' ? { size: formValues.size } : {}), payment_method: paymentMethodId, billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name, @@ -354,7 +394,12 @@ export const NewOrgForm = ({ return ( <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}> + <form + onSubmit={form.handleSubmit(onSubmit, (errors) => + trackFunnelError('org_creation', classifyValidationError('org_creation', errors), 'form') + )} + id={FORM_ID} + > <Panel title={ <div key="panel-title"> @@ -547,6 +592,11 @@ export const NewOrgForm = ({ onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} onError={(err) => { toast.error(err.message, { duration: 10_000 }) + trackFunnelError( + 'org_creation', + { errorCategory: 'payment', errorReason: 'payment_error' }, + 'toast' + ) setNewOrgLoading(false) resetPaymentMethod() }} diff --git a/apps/studio/components/interfaces/Organization/NewOrg/UpgradeExistingOrganizationCallout.tsx b/apps/studio/components/interfaces/Organization/NewOrg/UpgradeExistingOrganizationCallout.tsx index 54ea99da6bf..baf14b36a66 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/UpgradeExistingOrganizationCallout.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/UpgradeExistingOrganizationCallout.tsx @@ -1,4 +1,4 @@ -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx index 7ef47ad4777..e8d390785fc 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -1,5 +1,6 @@ import { OAuthScope } from '@supabase/shared-types/out/constants' import { Check, ChevronDown } from 'lucide-react' +import { useTheme } from 'next-themes' import { useMemo, useState } from 'react' import { Badge, @@ -11,11 +12,12 @@ import { CollapsibleTrigger, } from 'ui' import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants' import { LogoBox } from '@/components/layouts/InterstitialLayout' import { InlineLink } from '@/components/ui/InlineLink' -import { BASE_PATH, DOCS_URL } from '@/lib/constants' +import { DOCS_URL } from '@/lib/constants' const OAUTH_SCOPES_DOCS_URL = `${DOCS_URL}/guides/platform/oauth-apps/oauth-scopes` const PERMISSION_DETAILS_TRIGGER_CLASSNAME = @@ -165,44 +167,61 @@ const PERMISSION_GROUPS: PermissionGroup[] = [ }, ] -// Hardcode custom logs for known apps that have broken icons (e.g. due to CORS issues), to ensure they display properly -// in the UI. This should be deleted once we support CIMD MCP -const CUSTOM_LOGO_URLS = { - perplexity: `${BASE_PATH}/img/icons/perplexity-icon.svg`, - cursor: `${BASE_PATH}/img/icons/cursor-icon.svg`, - claude: `${BASE_PATH}/img/icons/claude-icon.svg`, - chatgpt: `${BASE_PATH}/img/icons/openai-icon.svg`, - openai: `${BASE_PATH}/img/icons/openai-icon.svg`, +const CUSTOM_LOGO_KEYS = { + perplexity: { icon: 'perplexity', hasDistinctDarkIcon: true }, + cursor: { icon: 'cursor', hasDistinctDarkIcon: true }, + claude: { icon: 'claude', hasDistinctDarkIcon: false }, + chatgpt: { icon: 'openai', hasDistinctDarkIcon: true }, + openai: { icon: 'openai', hasDistinctDarkIcon: true }, +} as const + +function getRequesterLogo({ + icon, + name, + useDarkVariant, +}: { + icon: string | null + name: string + useDarkVariant: boolean +}) { + const searchableText = `${icon ?? ''} ${name}`.toLowerCase() + + for (const [match, asset] of Object.entries(CUSTOM_LOGO_KEYS)) { + if (searchableText.includes(match)) { + const customLogoUrl = getMcpClientIconSrc({ + icon: asset.icon, + useDarkVariant, + hasDistinctDarkIcon: asset.hasDistinctDarkIcon, + }) + + if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true } + } + } + + return { src: icon || '', isKnownClient: false } } export const RequesterLogo = ({ icon, name }: { icon: string | null; name: string }) => { const [failedIcon, setFailedIcon] = useState<string | null>(null) + const { resolvedTheme } = useTheme() - const customLogoUrl = useMemo(() => { - for (const key of Object.keys(CUSTOM_LOGO_URLS)) { - if (icon?.toLocaleLowerCase().includes(key)) { - return CUSTOM_LOGO_URLS[key as keyof typeof CUSTOM_LOGO_URLS] - } + const logo = useMemo( + () => getRequesterLogo({ icon, name, useDarkVariant: resolvedTheme === 'dark' }), + [icon, name, resolvedTheme] + ) - if (name.toLocaleLowerCase().includes(key)) { - return CUSTOM_LOGO_URLS[key as keyof typeof CUSTOM_LOGO_URLS] - } - } - return icon || '' - }, [icon, name]) - - const showLetter = !customLogoUrl || failedIcon === customLogoUrl + const showLetter = !logo.src || failedIcon === logo.src return ( - <LogoBox> + <LogoBox className="bg-surface-75"> {showLetter ? ( <span className="text-lg font-medium text-foreground-light">{name.slice(0, 1)}</span> ) : ( <img alt={name} - src={customLogoUrl} - className="size-full object-cover" - onError={() => setFailedIcon(customLogoUrl)} + src={logo.src} + className={cn(logo.isKnownClient ? 'size-7 object-contain' : 'size-full object-cover')} + onError={() => setFailedIcon(logo.src)} /> )} </LogoBox> diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx index f7825359b82..2a74a01c673 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx @@ -1,6 +1,6 @@ import { Trash } from 'lucide-react' import { Button, TableCell, TableRow } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import CopyButton from '@/components/ui/CopyButton' import type { AuthorizedApp } from '@/data/oauth/authorized-apps-query' diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx index d27da2dba36..1005c82abb4 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx @@ -10,7 +10,7 @@ import { AlertDialogFooter, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useOAuthAppDeleteMutation } from '@/data/oauth/oauth-app-delete-mutation' import type { OAuthApp } from '@/data/oauth/oauth-apps-query' diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx index e28c76c4321..a8c75badf28 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx @@ -13,7 +13,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import CopyButton from '@/components/ui/CopyButton' import type { OAuthApp } from '@/data/oauth/oauth-apps-query' diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx index 1eef5169579..24675c72d06 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx @@ -31,10 +31,10 @@ import { DeleteAppModal } from './DeleteAppModal' import { OAuthAppRow } from './OAuthAppRow' import { PublishAppSidePanel } from './PublishAppSidePanel' import { RevokeAppModal } from './RevokeAppModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import CopyButton from '@/components/ui/CopyButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { Shortcut } from '@/components/ui/Shortcut' import { AuthorizedApp, useAuthorizedAppsQuery } from '@/data/oauth/authorized-apps-query' import { OAuthAppCreateResponse } from '@/data/oauth/oauth-app-create-mutation' diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx index 28f1db1e41c..c1442d2cb7d 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx @@ -11,7 +11,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useAuthorizedAppRevokeMutation } from '@/data/oauth/authorized-app-revoke-mutation' import type { AuthorizedApp } from '@/data/oauth/authorized-apps-query' diff --git a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/AppsList/AppsList.tsx b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/AppsList/AppsList.tsx index 6c761bb596d..9743025e82f 100644 --- a/apps/studio/components/interfaces/Organization/PrivateApps/Apps/AppsList/AppsList.tsx +++ b/apps/studio/components/interfaces/Organization/PrivateApps/Apps/AppsList/AppsList.tsx @@ -2,7 +2,7 @@ import { AppWindow, Plus } from 'lucide-react' import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { PromoteInstallationModal } from '../../Installations/PromoteInstallationModal' import { PrivateApp, usePrivateApps } from '../../PrivateAppsContext' diff --git a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx index 04e36069667..8df56451a7d 100644 --- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx @@ -5,7 +5,7 @@ import { useEffect, useEffectEvent, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import z from 'zod' @@ -16,7 +16,7 @@ import { SSOAdvancedSettings } from './SSOAdvancedSettings' import { SSODomains } from './SSODomains' import { SSOMetadata } from './SSOMetadata' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' diff --git a/apps/studio/components/interfaces/Organization/SecuritySettings.tsx b/apps/studio/components/interfaces/Organization/SecuritySettings.tsx index a8ea11d9644..a6f01b4adee 100644 --- a/apps/studio/components/interfaces/Organization/SecuritySettings.tsx +++ b/apps/studio/components/interfaces/Organization/SecuritySettings.tsx @@ -1,30 +1,19 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import Link from 'next/link' import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import { - Button, - Card, - CardContent, - CardFooter, - Form, - FormControl, - FormField, - Switch, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { z } from 'zod' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' import { useOrganizationMfaToggleMutation } from '@/data/organizations/organization-mfa-mutation' @@ -41,7 +30,13 @@ const schema = z.object({ export const SecuritySettings = () => { const { slug } = useParams() const { profile } = useProfile() - const { data: members } = useOrganizationMembersQuery({ slug }) + const { + data: members, + error: membersError, + isPending: isLoadingMembers, + isError: isMembersError, + isSuccess: isSuccessMembers, + } = useOrganizationMembersQuery({ slug }) const { can: canReadMfaConfig, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.READ, @@ -89,7 +84,19 @@ export const SecuritySettings = () => { }, [mfaConfig, form]) const hasMFAEnabled = - members?.find((member) => member.primary_email == profile?.primary_email)?.mfa_enabled || false + members?.find((member) => member.primary_email == profile?.primary_email)?.mfa_enabled ?? false + + const requiresPersonalMfa = isSuccessMembers && canUpdateMfaConfig && !hasMFAEnabled + + const isLoadingMfaEnforcementSettings = + isLoadingMfa || isLoadingPermissions || isLoadingEntitlement || isLoadingMembers + const hasMfaConfigError = (isErrorMfa || Boolean(mfaError)) && hasAccessToEnforceMfa + const canShowMfaEnforcementForm = + isSuccessMfa && hasAccessToEnforceMfa && isSuccessMembers && !requiresPersonalMfa + const isMfaEnforcementSwitchDisabled = + !hasAccessToEnforceMfa || !canUpdateMfaConfig || isUpdatingMfa + const isSaveMfaEnforcementDisabled = + isMfaEnforcementSwitchDisabled || isLoadingMfa || !form.formState.isDirty const onSubmit = (values: { enforceMfa: boolean }) => { if (!slug || !hasAccessToEnforceMfa) return @@ -108,7 +115,7 @@ export const SecuritySettings = () => { /> ) : ( <> - {isLoadingMfa || isLoadingPermissions || isLoadingEntitlement ? ( + {isLoadingMfaEnforcementSettings ? ( <Card> <CardContent> <GenericSkeletonLoader /> @@ -116,13 +123,31 @@ export const SecuritySettings = () => { </Card> ) : !canReadMfaConfig ? ( <NoPermission resourceText="view organization security settings" /> - ) : null} + ) : ( + requiresPersonalMfa && ( + <Admonition + type="note" + layout="horizontal" + title="Enable MFA on your account first" + description="You need to set up multi-factor authentication (MFA) on your own account before you can enforce it on your organization." + actions={ + <Button asChild variant="default"> + <Link href="/account/security">Set up MFA</Link> + </Button> + } + /> + ) + )} - {(isErrorMfa || mfaError) && hasAccessToEnforceMfa && ( + {isMembersError && ( + <AlertError error={membersError} subject="Failed to retrieve organization members" /> + )} + + {hasMfaConfigError && ( <AlertError error={mfaError} subject="Failed to retrieve MFA enforcement status" /> )} - {isSuccessMfa && hasAccessToEnforceMfa && ( + {canShowMfaEnforcementForm && ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> <Card> @@ -133,36 +158,16 @@ export const SecuritySettings = () => { render={({ field }) => ( <FormItemLayout layout="flex-row-reverse" + className="justify-between" label="Require MFA to access organization" description="Team members must have MFA enabled and a valid MFA session to access the organization and any projects." > <FormControl> - <Tooltip> - <TooltipTrigger asChild> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - disabled={ - !hasAccessToEnforceMfa || - !canUpdateMfaConfig || - !hasMFAEnabled || - isUpdatingMfa - } - /> - </TooltipTrigger> - {(!canUpdateMfaConfig || !hasMFAEnabled) && ( - <TooltipContent side="bottom"> - {!canUpdateMfaConfig ? ( - "You don't have permission to update MFA settings" - ) : ( - <> - <InlineLink href="/account/security">Enable MFA</InlineLink>{' '} - on your own account first - </> - )} - </TooltipContent> - )} - </Tooltip> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={isMfaEnforcementSwitchDisabled} + /> </FormControl> </FormItemLayout> )} @@ -183,16 +188,10 @@ export const SecuritySettings = () => { <Button variant="primary" type="submit" - disabled={ - !hasAccessToEnforceMfa || - !canUpdateMfaConfig || - isUpdatingMfa || - isLoadingMfa || - !form.formState.isDirty - } + disabled={isSaveMfaEnforcementDisabled} loading={isUpdatingMfa} > - Save changes + Save </Button> </CardFooter> </Card> diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 505991fb5e7..d5ee5abab4c 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -13,11 +13,11 @@ import { TableHeader, TableRow, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { MemberRow } from './MemberRow' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' import { useProfile } from '@/lib/profile' diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index e6c3a22657c..7acaeaaf284 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { Search } from 'lucide-react' import { useState } from 'react' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { InviteMemberButton } from './InviteMemberButton' diff --git a/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx index ee56a606675..d02039759ae 100644 --- a/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx @@ -7,7 +7,7 @@ import { BILLING_BREAKDOWN_METRICS } from '../BillingSettings/BillingBreakdown/B import { BillingMetric } from '../BillingSettings/BillingBreakdown/BillingMetric' import { ComputeMetric } from '../BillingSettings/BillingBreakdown/ComputeMetric' import { SectionContent } from './SectionContent' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ComputeUsageMetric, computeUsageMetricLabel, diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index 550e996f7ea..380ba5f795a 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { USAGE_APPROACHING_THRESHOLD } from '@/components/interfaces/Billing/Billing.constants' import { EgressType, PricingMetric } from '@/data/analytics/org-daily-stats-query' diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index e6fb9283d97..6eb76f17439 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -6,7 +6,7 @@ import Link from 'next/link' import { useQueryState } from 'nuqs' import { useMemo, useState } from 'react' import { Button, cn, CommandGroup, CommandItem } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { Restriction } from '../BillingSettings/Restriction' @@ -23,9 +23,9 @@ import { ScaffoldSection, ScaffoldTitle, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import DateRangePicker from '@/components/ui/DateRangePicker' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { DateRangePicker } from '@/components/ui/DateRangePicker' +import { NoPermission } from '@/components/ui/NoPermission' import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' import { useOrgDailyStatsQuery } from '@/data/analytics/org-daily-stats-query' import { useProjectDetailQuery } from '@/data/projects/project-detail-query' diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx index 53f64a0a574..4dd9d729457 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx @@ -13,9 +13,9 @@ import { } from '../Usage.utils' import UsageBarChart from '../UsageBarChart' import { ChartMeta } from './UsageSection' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import Panel from '@/components/ui/Panel' -import SparkBar from '@/components/ui/SparkBar' +import { SparkBar } from '@/components/ui/SparkBar' import type { OrgSubscription } from '@/data/subscriptions/types' import type { OrgMetricsUsage, OrgUsageResponse } from '@/data/usage/org-usage-query' import { USAGE_APPROACHING_THRESHOLD } from '@/lib/constants' diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index 8dc64ae58b7..9875d5abfe2 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -7,7 +7,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { SectionContent } from '../SectionContent' import { CategoryAttribute } from '../Usage.constants' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import Panel from '@/components/ui/Panel' import { PricingMetric } from '@/data/analytics/org-daily-stats-query' import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' diff --git a/apps/studio/components/interfaces/Organization/restriction.constants.tsx b/apps/studio/components/interfaces/Organization/restriction.constants.tsx index 26a440ffabd..dc13ebb5f8d 100644 --- a/apps/studio/components/interfaces/Organization/restriction.constants.tsx +++ b/apps/studio/components/interfaces/Organization/restriction.constants.tsx @@ -1,6 +1,6 @@ import dayjs from 'dayjs' import { type ReactNode } from 'react' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index 10b746c6695..28de171f715 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -4,7 +4,8 @@ import { useRouter } from 'next/router' import type { ReactNode } from 'react' import { toast } from 'sonner' import { Button, Card, CardContent } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getOrganizationInviteContent, diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx index 2209dfeb9a2..c4b94077499 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import { useSignOut } from '@/lib/auth' diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx index 7d762cba140..0c8096bcfb0 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx @@ -16,7 +16,7 @@ import { TabsList_Shadcn_ as TabsList, TabsTrigger_Shadcn_ as TabsTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import type { WebhookDelivery } from './PlatformWebhooks.types' import { formatDeliveryStatus, statusBadgeVariant } from './PlatformWebhooksView.utils' diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx index cb422c330ee..579f7c8c796 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx @@ -34,11 +34,8 @@ vi.mock('@/components/ui/ButtonTooltip', () => ({ ), })) -vi.mock('ui-patterns', async () => { - const actual = await vi.importActual<typeof import('ui-patterns')>('ui-patterns') - +vi.mock('ui-patterns/TimestampInfo', async () => { return { - ...actual, TimestampInfo: ({ utcTimestamp, className }: { utcTimestamp: string; className?: string }) => ( <span className={className}>{utcTimestamp}</span> ), diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx index e026d94b92d..358ada0b955 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -23,9 +23,9 @@ import { TableHeader, TableRow, } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { TanStackTableHeadSort } from 'ui-patterns/Table' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' import { statusBadgeVariant } from './PlatformWebhooksView.utils' diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx index 1373b733d37..766c1997268 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx @@ -17,8 +17,9 @@ import { TableHeadSort, TableRow, } from 'ui' -import { EmptyStatePresentational, TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import type { WebhookEndpoint } from './PlatformWebhooks.types' import { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils' diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx index a0b26c95a94..815751dc7b4 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx @@ -21,7 +21,7 @@ import { DropdownMenuTrigger, Label, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' diff --git a/apps/studio/components/interfaces/Project/APIKeys.tsx b/apps/studio/components/interfaces/Project/APIKeys.tsx index a023a338390..2a652fed5b3 100644 --- a/apps/studio/components/interfaces/Project/APIKeys.tsx +++ b/apps/studio/components/interfaces/Project/APIKeys.tsx @@ -3,9 +3,10 @@ import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { useParams } from 'common' import { Loader } from 'lucide-react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ConnectionIcon } from '@/components/interfaces/Connect/ConnectionIcon' import { ConnectButton } from '@/components/interfaces/ConnectButton/ConnectButton' @@ -187,9 +188,9 @@ export const APIKeys = () => { className="absolute inset-0 rounded-md -mt-px" style={{ backgroundImage: ` - linear-gradient(to top, hsl(var(--background-surface-100)/1) 0%, hsl(var(--background-surface-100)/1) 30%, hsl(var(--background-surface-75)/0) 100%), - linear-gradient(to right, hsl(var(--border-default)/0.33) 1px, transparent 1px), - linear-gradient(to bottom, hsl(var(--border-default)/0.33) 1px, transparent 1px) + linear-gradient(to top, oklch(from var(--background-surface-100) l c h / 1) 0%, oklch(from var(--background-surface-100) l c h / 1) 30%, oklch(from var(--background-surface-75) l c h / 0) 100%), + linear-gradient(to right, oklch(from var(--border-default) l c h / 0.33) 1px, transparent 1px), + linear-gradient(to bottom, oklch(from var(--border-default) l c h / 0.33) 1px, transparent 1px) `, backgroundSize: '100% 100%, 15px 15px, 15px 15px', backgroundPosition: '0 0, 0 0, 0 0', diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx index cc096785354..8fabf212664 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { Fragment, type ReactNode } from 'react' import SVG from 'react-inlinesvg' import { Button, cn } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { navigateToSection } from './Content/Content.utils' import { API_DOCS_CATEGORIES, DOCS_CONTENT, DOCS_MENU } from './ProjectAPIDocs.constants' diff --git a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx index f73e4778acc..9651283d523 100644 --- a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx @@ -14,7 +14,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' diff --git a/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx index 036bf2a6633..70fd51bb8b0 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx @@ -9,7 +9,7 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { ComputeBadge } from 'ui-patterns' +import { ComputeBadge } from 'ui-patterns/ComputeBadge' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { sizes } from './ProjectCreation.constants' diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index d10212ebbba..9593bf99ec8 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -1,4 +1,4 @@ -import { useFlag, useParams } from 'common' +import { useFeatureFlags, useParams } from 'common' import { UseFormReturn } from 'react-hook-form' import type { CloudProvider } from 'shared-data' import { @@ -22,14 +22,14 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateProjectForm } from './ProjectCreation.schema' import { getAvailableRegions } from './ProjectCreation.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' import { useDefaultRegionQuery } from '@/data/misc/get-default-region-query' import { useOrganizationAvailableRegionsQuery } from '@/data/organizations/organization-available-regions-query' import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import type { DesiredInstanceSize } from '@/data/projects/new-project.constants' -import { BASE_PATH, PROVIDERS } from '@/lib/constants' +import { BASE_PATH } from '@/lib/constants' interface RegionSelectorProps { form: UseFormReturn<CreateProjectForm> @@ -69,14 +69,15 @@ export const RegionSelector = ({ const { slug } = useParams() const cloudProvider = form.getValues('cloudProvider') as CloudProvider - const smartRegionEnabled = useFlag('enableSmartRegion') + const { hasLoaded: flagsLoaded } = useFeatureFlags() + const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' const { data: statusData } = useIncidentStatusQuery() const { incidents = [] } = statusData ?? {} const { isPending: isLoadingDefaultRegion } = useDefaultRegionQuery( { cloudProvider }, - { enabled: !smartRegionEnabled } + { enabled: flagsLoaded && !smartRegionEnabled } ) const { @@ -99,7 +100,7 @@ export const RegionSelector = ({ availableRegionsData?.recommendations.specific.map((region) => region.code) ) - const availableRegions = getAvailableRegions(PROVIDERS[cloudProvider].id) + const availableRegions = getAvailableRegions(cloudProvider) const regionsArray = Object.entries(availableRegions).map(([_key, value]) => { return { code: value.code, diff --git a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx index 254f41a5f70..bca84b51169 100644 --- a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx @@ -12,7 +12,7 @@ import { TooltipTrigger, useWatch, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateProjectForm } from './ProjectCreation.schema' diff --git a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx index a3da7867b7a..220d9d71063 100644 --- a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx @@ -3,7 +3,7 @@ import dayjs from 'dayjs' import { Archive, Cpu, Database, GitBranch, Github } from 'lucide-react' import { useMemo } from 'react' import { cn, Skeleton } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { HighAvailabilityBadge } from './HighAvailabilityBadge' import { ServiceStatus } from './ServiceStatus' diff --git a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx index 8a8f91060f3..f26b15f6112 100644 --- a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { BarChart, Shield } from 'lucide-react' import { useCallback, useMemo } from 'react' import { AiIconAnimation, Badge, Button, Card, CardContent, CardHeader, CardTitle, cn } from 'ui' -import { Row } from 'ui-patterns' +import { Row } from 'ui-patterns/Row' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { Markdown } from '../Markdown' diff --git a/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx b/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx index c71c93e3d89..9ad8c75030c 100644 --- a/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx @@ -16,7 +16,7 @@ import type { CSSProperties, DragEvent, ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Button } from 'ui' -import { Row } from 'ui-patterns' +import { Row } from 'ui-patterns/Row' import { SnippetDropdown } from '@/components/interfaces/ProjectHome/SnippetDropdown' import { ReportBlock } from '@/components/interfaces/Reports/ReportBlock/ReportBlock' diff --git a/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx b/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx index 766d93cb318..0cc0ec22144 100644 --- a/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx @@ -12,7 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getConnectionStrings } from '@/components/interfaces/Connect/DatabaseSettings.utils' import { useAPIKeys } from '@/data/api-keys/api-keys-query' diff --git a/apps/studio/components/interfaces/ProjectHome/ProjectUsageSection.tsx b/apps/studio/components/interfaces/ProjectHome/ProjectUsageSection.tsx index c60348b5187..78106a90b9c 100644 --- a/apps/studio/components/interfaces/ProjectHome/ProjectUsageSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ProjectUsageSection.tsx @@ -4,10 +4,15 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle, Loading, WarningIcon } from 'ui' -import { Row } from 'ui-patterns' import { ChartEmptyState } from 'ui-patterns/Chart' import { LogsBarChart } from 'ui-patterns/LogsBarChart' +import { Row } from 'ui-patterns/Row' +import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + buildUnifiedLogsUrl, + type UnifiedLogType, +} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' import { ChartIntervalDropdown } from '@/components/ui/Logs/ChartIntervalDropdown' import { CHART_INTERVALS } from '@/components/ui/Logs/logs.utils' @@ -34,6 +39,7 @@ type ServiceEntry = { title: string href?: string route: string + logType: UnifiedLogType enabled: boolean } @@ -55,6 +61,7 @@ export const ProjectUsageSection = () => { ]) const { getEntitlementMax } = useCheckEntitlements('log.retention_days') const retentionDays = getEntitlementMax() + const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const DEFAULT_INTERVAL: ChartIntervalKey = retentionDays !== undefined && retentionDays < 7 ? '1hr' : '1day' @@ -116,6 +123,7 @@ export const ProjectUsageSection = () => { title: 'Database requests', href: `/project/${projectRef}/editor`, route: '/logs/postgres-logs', + logType: 'postgres', enabled: true, }, { @@ -123,6 +131,7 @@ export const ProjectUsageSection = () => { title: 'Auth requests', href: `/project/${projectRef}/auth/users`, route: '/logs/auth-logs', + logType: 'auth', enabled: authEnabled, }, { @@ -130,6 +139,7 @@ export const ProjectUsageSection = () => { title: 'Storage requests', href: `/project/${projectRef}/storage/buckets`, route: '/logs/storage-logs', + logType: 'storage', enabled: storageEnabled, }, { @@ -137,6 +147,7 @@ export const ProjectUsageSection = () => { title: 'Realtime requests', href: `/project/${projectRef}/realtime/inspector`, route: '/logs/realtime-logs', + logType: 'realtime', enabled: true, }, ], @@ -179,27 +190,31 @@ export const ProjectUsageSection = () => { [serviceBase, filledCharts, isLoading, error] ) - const handleBarClick = - (logRoute: string, serviceKey: ServiceKey) => (datum: LogsBarChartDatum) => { - if (!datum?.timestamp) return + const handleBarClick = (service: ServiceEntry) => (datum: LogsBarChartDatum) => { + if (!datum?.timestamp) return - const datumTimestamp = dayjs(datum.timestamp).toISOString() - const start = dayjs(datumTimestamp).subtract(1, 'minute').toISOString() - const end = dayjs(datumTimestamp).add(1, 'minute').toISOString() + const datumTimestamp = dayjs(datum.timestamp).toISOString() + const start = dayjs(datumTimestamp).subtract(1, 'minute').toISOString() + const end = dayjs(datumTimestamp).add(1, 'minute').toISOString() + if (isUnifiedLogsEnabled) { + router.push( + buildUnifiedLogsUrl({ projectRef: projectRef!, logType: service.logType, start, end }) + ) + } else { const queryParams = new URLSearchParams({ iso_timestamp_start: start, iso_timestamp_end: end, }) - - router.push(`/project/${projectRef}${logRoute}?${queryParams.toString()}`) - - track('home_project_usage_chart_clicked', { - service_type: serviceKey, - bar_timestamp: datum.timestamp, - }) + router.push(`/project/${projectRef}${service.route}?${queryParams.toString()}`) } + track('home_project_usage_chart_clicked', { + service_type: service.key, + bar_timestamp: datum.timestamp, + }) + } + const enabledServices = services.filter((s) => s.enabled) const totalRequests = enabledServices.reduce((sum, s) => sum + (s.total || 0), 0) @@ -252,7 +267,7 @@ export const ProjectUsageSection = () => { data={s.data} error={s.error} DateTimeFormat={datetimeFormat} - onBarClick={handleBarClick(s.route, s.key)} + onBarClick={handleBarClick(s)} hideZeroValues={true} chartConfig={{ error_count: { diff --git a/apps/studio/components/interfaces/ProjectHome/ProjectUsageSectionDeltas.tsx b/apps/studio/components/interfaces/ProjectHome/ProjectUsageSectionDeltas.tsx index b70c9f85964..7f3385e803b 100644 --- a/apps/studio/components/interfaces/ProjectHome/ProjectUsageSectionDeltas.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ProjectUsageSectionDeltas.tsx @@ -3,8 +3,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle, cn, Loading } from 'ui' -import { Row } from 'ui-patterns' import { LogsBarChart } from 'ui-patterns/LogsBarChart' +import { Row } from 'ui-patterns/Row' import { buildSortedServiceCards, @@ -14,7 +14,12 @@ import { type ChartIntervalKey, type LogsBarChartDatum, } from './ProjectUsage.metrics' +import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useServiceHealthMetrics } from '@/components/interfaces/Observability/useServiceHealthMetrics' +import { + buildUnifiedLogsUrl, + type UnifiedLogType, +} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' import { ChartIntervalDropdown } from '@/components/ui/Logs/ChartIntervalDropdown' import { CHART_INTERVALS } from '@/components/ui/Logs/logs.utils' @@ -35,6 +40,7 @@ type ServiceEntry = { title: string href?: string route: string + logType: UnifiedLogType enabled: boolean } @@ -48,6 +54,7 @@ export const ProjectUsageSectionDeltas = () => { 'project_storage:all', ]) const { isEnabled: dataApiEnabled } = useIsDataApiEnabled({ projectRef }) + const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const { getEntitlementMax } = useCheckEntitlements('log.retention_days') const retentionDays = getEntitlementMax() @@ -71,6 +78,7 @@ export const ProjectUsageSectionDeltas = () => { title: 'Postgres', href: `/project/${projectRef}/editor`, route: '/logs/postgres-logs', + logType: 'postgres', enabled: true, }, { @@ -78,6 +86,7 @@ export const ProjectUsageSectionDeltas = () => { title: 'Edge Functions', href: `/project/${projectRef}/functions`, route: '/logs/edge-functions-logs', + logType: 'edge function', enabled: true, }, { @@ -85,6 +94,7 @@ export const ProjectUsageSectionDeltas = () => { title: 'Auth', href: `/project/${projectRef}/auth/users`, route: '/logs/auth-logs', + logType: 'auth', enabled: authEnabled, }, { @@ -92,6 +102,7 @@ export const ProjectUsageSectionDeltas = () => { title: 'Storage', href: `/project/${projectRef}/storage/buckets`, route: '/logs/storage-logs', + logType: 'storage', enabled: storageEnabled, }, { @@ -99,6 +110,7 @@ export const ProjectUsageSectionDeltas = () => { title: 'Realtime', href: `/project/${projectRef}/realtime/inspector`, route: '/logs/realtime-logs', + logType: 'realtime', enabled: true, }, { @@ -106,6 +118,7 @@ export const ProjectUsageSectionDeltas = () => { title: 'API Gateway', href: `/project/${projectRef}/integrations/data_api/overview`, route: '/logs/edge-logs', + logType: 'edge', enabled: dataApiEnabled, }, ], @@ -127,27 +140,28 @@ export const ProjectUsageSectionDeltas = () => { totalErrors ) - const handleBarClick = - (logRoute: string, serviceKey: HomeServiceKey) => (datum: LogsBarChartDatum) => { - if (!datum?.timestamp) return + const handleBarClick = (service: ServiceEntry) => (datum: LogsBarChartDatum) => { + if (!datum?.timestamp) return + const { start, end } = getBucketLogRange(datum.timestamp, interval) + + if (isUnifiedLogsEnabled) { + router.push( + buildUnifiedLogsUrl({ projectRef: projectRef!, logType: service.logType, start, end }) + ) + } else { // Logs explorer reads the range from `its`/`ite` (iso_timestamp_start/end // only set the label). - const { start, end } = getBucketLogRange(datum.timestamp, interval) - - const queryParams = new URLSearchParams({ - its: start, - ite: end, - }) - - router.push(`/project/${projectRef}${logRoute}?${queryParams.toString()}`) - - track('home_project_usage_chart_clicked', { - service_type: serviceKey, - bar_timestamp: datum.timestamp, - }) + const queryParams = new URLSearchParams({ its: start, ite: end }) + router.push(`/project/${projectRef}${service.route}?${queryParams.toString()}`) } + track('home_project_usage_chart_clicked', { + service_type: service.key, + bar_timestamp: datum.timestamp, + }) + } + return ( <div className="space-y-6"> <div className="flex flex-row justify-between items-center gap-x-2"> @@ -207,14 +221,14 @@ export const ProjectUsageSectionDeltas = () => { <div className="flex flex-col items-end"> <div className="flex items-center gap-2"> <div className="w-1.5 h-1.5 bg-warning rounded-full" /> - <span className="heading-meta">Warn</span> + <span className="heading-meta">Warnings</span> </div> <span className="text-foreground text-base">{s.warn.toLocaleString()}</span> </div> <div className="flex flex-col items-end"> <div className="flex items-center gap-2"> <div className="w-1.5 h-1.5 bg-destructive rounded-full" /> - <span className="heading-meta">Err</span> + <span className="heading-meta">Errors</span> </div> <span className="text-foreground text-base">{s.err.toLocaleString()}</span> </div> @@ -226,12 +240,12 @@ export const ProjectUsageSectionDeltas = () => { isFullHeight data={s.data} DateTimeFormat={datetimeFormat} - onBarClick={disabled ? undefined : handleBarClick(s.route, s.key)} + onBarClick={disabled ? undefined : handleBarClick(s)} hideZeroValues={true} chartConfig={{ error_count: { label: 'Errors' }, warning_count: { label: 'Warnings' }, - ok_count: { label: 'Requests' }, + ok_count: { label: 'Infos' }, }} EmptyState={ isLoading ? ( diff --git a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx index e706e00ee5c..079ba9eaf09 100644 --- a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx @@ -1,26 +1,85 @@ import { useParams } from 'common' import dayjs from 'dayjs' -import { ChevronRight, Loader2 } from 'lucide-react' +import { AlertTriangle, CheckCircle2, ChevronRight, Loader2 } from 'lucide-react' import Link from 'next/link' import { cn, HoverCard, HoverCardContent, HoverCardTrigger, InfoIcon } from 'ui' -import { - extractDbSchema, - ProjectServiceStatus, - StatusIcon, - StatusMessage, -} from '../Home/ServiceStatus' +import { useUnifiedLogsPreview } from '../App/FeaturePreview/FeaturePreviewContext' import { InlineLink } from '@/components/ui/InlineLink' import { SingleStat } from '@/components/ui/SingleStat' import { useBranchesQuery } from '@/data/branches/branches-query' import { useEdgeFunctionServiceStatusQuery } from '@/data/service-status/edge-functions-status-query' -import { useProjectServiceStatusQuery } from '@/data/service-status/service-status-query' +import { + useProjectServiceStatusQuery, + type ProjectServiceStatus as APIProjectServiceStatus, + type ServiceHealthResponse, +} from '@/data/service-status/service-status-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' const SERVICE_STATUS_THRESHOLD = 5 // minutes +type ProjectServiceStatus = APIProjectServiceStatus | 'DISABLED' + +const iconProps = { + size: 18, + strokeWidth: 1.5, +} +const LoaderIcon = () => <Loader2 {...iconProps} className="animate-spin" /> +const AlertIcon = () => <AlertTriangle {...iconProps} /> +const CheckIcon = () => <CheckCircle2 {...iconProps} className="text-brand" /> + +export const StatusIcon = ({ + isLoading, + isProjectNew, + projectStatus, +}: { + isLoading: boolean + isProjectNew: boolean + projectStatus?: ProjectServiceStatus +}) => { + // + if (projectStatus === 'ACTIVE_HEALTHY') return <CheckIcon /> + if (projectStatus === 'DISABLED') return <AlertIcon /> + if (projectStatus === 'COMING_UP') return <LoaderIcon /> + if (isLoading) return <LoaderIcon /> + // isProjectNew has to be above UNHEALTHY because in the first few minutes, some services might be starting up and show as UNHEALTHY + if (isProjectNew) return <LoaderIcon /> + if (projectStatus === 'UNHEALTHY') return <AlertIcon /> + return <AlertIcon /> +} + +const StatusMessage = ({ + status, + isLoading, + isProjectNew, +}: { + isLoading: boolean + isProjectNew: boolean + status?: ProjectServiceStatus +}) => { + if (isLoading) return 'Checking status' + if (status === 'DISABLED') return 'Disabled' + if (status === 'UNHEALTHY') return 'Unhealthy' + if (status === 'COMING_UP') return 'Coming up...' + if (status === 'ACTIVE_HEALTHY') return 'Healthy' + // isProjectNew has to be after all other statuses + if (isProjectNew) return 'Coming up...' + if (status) return status + return 'Unable to connect' +} + +/* + * Extract the db_schema from the response.info object + */ +const extractDbSchema = (response: ServiceHealthResponse | undefined) => { + if (response?.info && 'db_schema' in response.info) { + return response.info.db_schema + } + return undefined +} + /** * [Joshen] JFYI before we go live with this, we need to revisit the migrations section * as I don't think it should live in the ServiceStatus component since its not indicative @@ -42,6 +101,7 @@ const SERVICE_STATUS_THRESHOLD = 5 // minutes export const ServiceStatus = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const { projectAuthAll: authEnabled, @@ -123,7 +183,7 @@ export const ServiceStatus = () => { docsUrl: undefined, isLoading: isLoading, status: dbStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/postgres-logs', + logsUrl: isUnifiedLogsEnabled ? '/logs?filter=log_type:eq:postgres' : '/logs/postgres-logs', }, { name: 'PostgREST', @@ -132,7 +192,7 @@ export const ServiceStatus = () => { isLoading, // If PostgREST has an empty schema, it means it's been disabled status: extractDbSchema(restStatus) === '' ? 'DISABLED' : (restStatus?.status ?? 'UNHEALTHY'), - logsUrl: '/logs/postgrest-logs', + logsUrl: isUnifiedLogsEnabled ? '/logs?filter=log_type:eq:postgrest' : '/logs/postgrest-logs', }, ...(authEnabled ? [ @@ -142,7 +202,7 @@ export const ServiceStatus = () => { docsUrl: undefined, isLoading, status: authStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/auth-logs', + logsUrl: isUnifiedLogsEnabled ? '/logs?filter=log_type:eq:auth' : '/logs/auth-logs', }, ] : []), @@ -154,7 +214,9 @@ export const ServiceStatus = () => { docsUrl: undefined, isLoading, status: realtimeStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/realtime-logs', + logsUrl: isUnifiedLogsEnabled + ? '/logs?filter=log_type:eq:realtime' + : '/logs/realtime-logs', }, ] : []), @@ -166,7 +228,9 @@ export const ServiceStatus = () => { docsUrl: undefined, isLoading, status: storageStatus?.status ?? 'UNHEALTHY', - logsUrl: '/logs/storage-logs', + logsUrl: isUnifiedLogsEnabled + ? '/logs?filter=log_type:eq:storage' + : '/logs/storage-logs', }, ] : []), @@ -182,7 +246,9 @@ export const ServiceStatus = () => { : isLoading ? ('COMING_UP' as const) : ('UNHEALTHY' as const), - logsUrl: '/logs/edge-functions-logs', + logsUrl: isUnifiedLogsEnabled + ? '/logs?filter=log_type:eq:edge+function' + : '/logs/edge-functions-logs', }, ] : []), diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx index 6ba495898b4..0d022f375cb 100644 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx @@ -14,7 +14,7 @@ import { parseSupamonitorLogs, transformLogsToChartData, } from './utils/supamonitor.utils' -import useLogsQuery from '@/hooks/analytics/useLogsQuery' +import { useLogsQuery } from '@/hooks/analytics/useLogsQuery' dayjs.extend(utc) diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx index 0c3911f40cf..448eef47dae 100644 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx @@ -181,13 +181,13 @@ export const QueryInsightsChart = ({ dataKey="time" tick={false} tickLine={false} - axisLine={{ stroke: 'hsl(var(--border-default))' }} + axisLine={{ stroke: 'var(--border-default)' }} height={1} /> <YAxis axisLine={false} tickLine={false} - tick={{ fontSize: 10, fill: 'hsl(var(--foreground-muted))' }} + tick={{ fontSize: 10, fill: 'var(--foreground-muted)' }} tickCount={3} width={40} orientation="left" @@ -208,7 +208,7 @@ export const QueryInsightsChart = ({ <CartesianGrid horizontal={true} vertical={false} - stroke="hsl(var(--border-default))" + stroke="var(--border-default)" strokeOpacity={0.5} /> {legendItems.map((item) => ( diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx index 6c69d1eb004..d870e60f581 100644 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx @@ -11,7 +11,7 @@ export const QueryInsightsHealthScore = ({ <div className="h-12 w-12 rounded-full flex items-center justify-center" style={{ - background: `conic-gradient(${color} ${score * 3.6}deg, hsl(var(--border-default)) ${score * 3.6}deg)`, + background: `conic-gradient(${color} ${score * 3.6}deg, var(--border-default) ${score * 3.6}deg)`, }} > <div diff --git a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorNotice.tsx b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorNotice.tsx deleted file mode 100644 index f2443402989..00000000000 --- a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexAdvisorNotice.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { LOCAL_STORAGE_KEYS } from 'common' -import { useParams } from 'common/hooks' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns' - -import { EnableIndexAdvisorButton } from './EnableIndexAdvisorButton' -import { useIndexAdvisorStatus } from '@/components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { BASE_PATH } from '@/lib/constants' - -export const IndexAdvisorNotice = () => { - const { ref } = useParams() - const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus() - const [isDismissed, setIsDismissed] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.INDEX_ADVISOR_NOTICE_DISMISSED(ref ?? ''), - false - ) - - if (!isIndexAdvisorAvailable || isIndexAdvisorEnabled || isDismissed) return null - - return ( - <div className="px-6"> - <Admonition showIcon={false} type="tip" className="relative overflow-hidden mb-4"> - <div className="absolute -inset-16 z-0 opacity-50"> - <img - src={`${BASE_PATH}/img/reports/bg-grafana-dark.svg`} - alt="Index Advisor" - className="w-full h-full object-cover object-right hidden dark:block" - /> - <img - src={`${BASE_PATH}/img/reports/bg-grafana-light.svg`} - alt="Index Advisor" - className="w-full h-full object-cover object-right dark:hidden" - /> - <div className="absolute inset-0 bg-linear-to-r from-background-alternative to-transparent" /> - </div> - <div className="relative z-10 flex flex-col md:flex-row md:items-center gap-y-2 md:gap-x-8 justify-between px-2 py-1"> - <div className="flex flex-col gap-y-0.5"> - <div className="flex flex-col gap-y-2 items-start"> - <p className="text-sm font-medium">Enable Index Advisor</p> - </div> - <p className="text-sm text-foreground-lighter text-balance"> - Recommends indexes to improve query performance. - </p> - </div> - <div className="flex items-center gap-x-2"> - <Button - variant="default" - size="tiny" - onClick={() => setIsDismissed(true)} - aria-label="Dismiss notification" - > - Dismiss - </Button> - <EnableIndexAdvisorButton /> - </div> - </div> - </Admonition> - </div> - ) -} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx index 621b0845ece..4f267a61bef 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx @@ -99,9 +99,7 @@ export const QueryDetail = ({ selectedRow, onClickViewSuggestion, onClose }: Que > <SqlMonacoBlock value={query} - height={322} - lineNumbers="off" - wrapperClassName={cn('pl-3 bg-surface-100', !isExpanded && 'pointer-events-none')} + wrapperClassName={cn('bg-surface-100', !isExpanded && 'pointer-events-none')} /> {isLinterWarning && ( <Alert diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx index 0e6f5aef43c..c6c64a67590 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx @@ -14,7 +14,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' @@ -30,7 +30,7 @@ import { IndexImprovementText } from './IndexAdvisor/IndexImprovementText' import { QueryPanelContainer, QueryPanelScoreSection, QueryPanelSection } from './QueryPanel' import { QueryPerformanceRow } from './QueryPerformance.types' import { useIndexAdvisorStatus } from '@/components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx deleted file mode 100644 index 281786220ec..00000000000 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import { Loader2 } from 'lucide-react' -import { useMemo, useState } from 'react' -import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' - -import type { ChartDataPoint } from '../QueryInsights/QueryInsights.types' -import { QUERY_PERFORMANCE_CHART_TABS } from './QueryPerformance.constants' -import { ComposedChart } from '@/components/ui/Charts/ComposedChart' -import type { MultiAttribute } from '@/components/ui/Charts/ComposedChart.utils' - -interface QueryPerformanceChartProps { - dateRange?: { - period_start: { date: string; time_period: string } - period_end: { date: string; time_period: string } - interval: string - } - onDateRangeChange?: (from: string, to: string) => void - chartData: ChartDataPoint[] - isLoading: boolean - error: any - currentSelectedQuery: string | null - parsedLogs: any[] -} - -const QueryMetricBlock = ({ - label, - value, -}: { - label: string - value: string | number | undefined -}) => { - return ( - <div className="flex flex-col gap-0.5 text-xs"> - <span className="font-mono text-xs text-foreground-lighter uppercase">{label}</span> - <span className="text-lg tabular-nums">{value}</span> - </div> - ) -} - -const formatTimeValue = (value: number): string => { - if (value >= 1000) { - return `${(value / 1000).toFixed(1)}s` - } - return `${value.toFixed(1)}ms` -} - -const formatNumberValue = (value: number): string => { - return value.toLocaleString() -} - -export const QueryPerformanceChart = ({ - onDateRangeChange, - chartData, - isLoading, - error, - currentSelectedQuery, - parsedLogs, -}: QueryPerformanceChartProps) => { - const [selectedMetric, setSelectedMetric] = useState('query_latency') - - const currentMetrics = useMemo(() => { - if (!chartData || chartData.length === 0) return [] - - switch (selectedMetric) { - case 'query_latency': { - const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0) - const trueP95 = - totalCalls > 0 - ? chartData.reduce((sum, d) => sum + d.p95_time * d.calls, 0) / totalCalls - : 0 - - return [ - { - label: 'Average p95', - value: `${Math.round(trueP95)}ms`, - }, - ] - } - case 'rows_read': { - const totalRowsRead = chartData.reduce((sum, d) => sum + d.rows_read, 0) - - return [ - { - label: 'Total Rows Read', - value: totalRowsRead.toLocaleString(), - }, - ] - } - case 'calls': { - const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0) - - return [ - { - label: 'Total Calls', - value: totalCalls.toLocaleString(), - }, - ] - } - case 'cache_hits': { - const totalHits = chartData.reduce((sum, d) => sum + d.cache_hits, 0) - const totalMisses = chartData.reduce((sum, d) => sum + d.cache_misses, 0) - const total = totalHits + totalMisses - const hitRate = total > 0 ? (totalHits / total) * 100 : 0 - - return [ - { - label: 'Cache Hit Rate', - value: `${hitRate.toFixed(2)}%`, - }, - ] - } - default: - return [] - } - }, [chartData, selectedMetric]) - - const transformedChartData = useMemo(() => { - if (selectedMetric !== 'query_latency') return chartData - - const transformed = chartData.map((dataPoint) => ({ - ...dataPoint, - p50_time: parseFloat((dataPoint.p50_time / 1000).toFixed(3)), - p95_time: parseFloat((dataPoint.p95_time / 1000).toFixed(3)), - })) - - return transformed - }, [chartData, selectedMetric]) - - const querySpecificData = useMemo(() => { - if (!currentSelectedQuery || !parsedLogs.length) return null - - const normalizedSelected = currentSelectedQuery.replace(/\s+/g, ' ').trim() - const queryLogs = parsedLogs.filter((log) => { - const normalized = (log.query || '').replace(/\s+/g, ' ').trim() - return normalized === normalizedSelected - }) - - const queryDataMap = new Map< - number, - { - time: number - rows_read: number - calls: number - cache_hits: number - } - >() - - queryLogs.forEach((log) => { - const time = new Date(log.timestamp).getTime() - const meanTime = log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0 - const rowsRead = log.rows_read ?? log.rows ?? 0 - const calls = log.calls ?? 0 - const cacheHits = log.shared_blks_hit ?? log.cache_hits ?? 0 - - queryDataMap.set(time, { - time: parseFloat(String(meanTime)), - rows_read: parseFloat(String(rowsRead)), - calls: parseFloat(String(calls)), - cache_hits: parseFloat(String(cacheHits)), - }) - }) - - return queryDataMap - }, [currentSelectedQuery, parsedLogs]) - - const mergedChartData = useMemo(() => { - if (!querySpecificData || !currentSelectedQuery) { - return transformedChartData - } - - return transformedChartData.map((dataPoint) => { - const queryData = querySpecificData.get(dataPoint.period_start) - - return { - ...dataPoint, - selected_query_time: - queryData?.time !== undefined - ? selectedMetric === 'query_latency' - ? queryData.time / 1000 - : queryData.time - : null, - selected_query_rows_read: queryData?.rows_read !== undefined ? queryData.rows_read : null, - selected_query_calls: queryData?.calls !== undefined ? queryData.calls : null, - selected_query_cache_hits: - queryData?.cache_hits !== undefined ? queryData.cache_hits : null, - } - }) - }, [transformedChartData, querySpecificData, currentSelectedQuery, selectedMetric]) - - const getChartAttributes = useMemo((): MultiAttribute[] => { - const attributeMap: Record<string, MultiAttribute[]> = { - query_latency: [ - { - attribute: 'p50_time', - label: 'p50', - provider: 'logs', - type: 'line', - color: { light: '#8B5CF6', dark: '#8B5CF6' }, - }, - { - attribute: 'p95_time', - label: 'p95', - provider: 'logs', - type: 'line', - color: { light: '#65BCD9', dark: '#65BCD9' }, - }, - ], - rows_read: [ - { - attribute: 'rows_read', - label: 'Rows Read', - provider: 'logs', - }, - ], - calls: [ - { - attribute: 'calls', - label: 'Calls', - provider: 'logs', - }, - ], - cache_hits: [ - { - attribute: 'cache_hits', - label: 'Cache Hits', - provider: 'logs', - type: 'line', - color: { light: '#10B981', dark: '#10B981' }, - }, - ], - } - - const baseAttributes = attributeMap[selectedMetric] || [] - - if (currentSelectedQuery && querySpecificData) { - const dimmedBaseAttributes = baseAttributes.map((attr) => ({ - ...attr, - color: attr.color - ? { light: attr.color.light + '4D', dark: attr.color.dark + '4D' } - : attr.color, - })) - - const selectedQueryAttributes: Record<string, MultiAttribute> = { - query_latency: { - attribute: 'selected_query_time', - label: 'Selected Query', - provider: 'logs', - type: 'line', - color: { light: '#10B981', dark: '#10B981' }, - strokeWidth: 3, - }, - rows_read: { - attribute: 'selected_query_rows_read', - label: 'Selected Query', - provider: 'logs', - type: 'line', - color: { light: '#F59E0B', dark: '#F59E0B' }, - strokeWidth: 3, - }, - calls: { - attribute: 'selected_query_calls', - label: 'Selected Query', - provider: 'logs', - type: 'line', - color: { light: '#EC4899', dark: '#EC4899' }, - strokeWidth: 3, - }, - cache_hits: { - attribute: 'selected_query_cache_hits', - label: 'Selected Query', - provider: 'logs', - type: 'line', - color: { light: '#8B5CF6', dark: '#8B5CF6' }, - strokeWidth: 3, - }, - } - - const selectedQueryAttr = selectedQueryAttributes[selectedMetric] - if (selectedQueryAttr) { - return [...dimmedBaseAttributes, selectedQueryAttr] - } - } - - return baseAttributes - }, [selectedMetric, currentSelectedQuery, querySpecificData]) - - const updateDateRange = (from: string, to: string) => { - onDateRangeChange?.(from, to) - } - - const getYAxisFormatter = useMemo(() => { - if (selectedMetric === 'query_latency') { - return formatTimeValue - } - return formatNumberValue - }, [selectedMetric]) - - return ( - <div className="bg-surface-200 border-t"> - <Tabs_Shadcn_ - value={selectedMetric} - onValueChange={(value) => setSelectedMetric(value as string)} - className="w-full" - > - <TabsList_Shadcn_ className="flex justify-start rounded-none gap-x-4 border-b mt-0! pt-0 px-6"> - {QUERY_PERFORMANCE_CHART_TABS.map((tab) => ( - <TabsTrigger_Shadcn_ - key={tab.id} - value={tab.id} - className="flex items-center gap-2 text-xs py-3 border-b font-mono uppercase" - > - {tab.label} - </TabsTrigger_Shadcn_> - ))} - </TabsList_Shadcn_> - - <TabsContent_Shadcn_ value={selectedMetric} className="bg-surface-200 mt-0 h-inherit"> - <div className="w-full flex items-center justify-center min-h-[282px]"> - {isLoading ? ( - <Loader2 size={20} className="animate-spin text-foreground-lighter" /> - ) : error ? ( - <p className="text-sm text-foreground-light text-center h-full flex items-center justify-center"> - Error loading chart data - </p> - ) : ( - <div className="w-full flex flex-col h-full px-6 py-4"> - <div className="flex gap-6 mb-4"> - {currentMetrics.map((metric, index) => ( - <QueryMetricBlock key={index} label={metric.label} value={metric.value} /> - ))} - </div> - <ComposedChart - data={mergedChartData as any} - attributes={getChartAttributes} - yAxisKey={getChartAttributes[0]?.attribute || ''} - xAxisKey="period_start" - title="" - customDateFormat="MMM D, YYYY hh:mm A" - hideChartType={true} - hideHighlightArea={true} - showTooltip={true} - showGrid={true} - showLegend={true} - showTotal={false} - showMaxValue={false} - updateDateRange={updateDateRange} - YAxisProps={{ - tick: true, - width: 60, - tickFormatter: getYAxisFormatter, - }} - xAxisIsDate={true} - className="mt-2" - /> - </div> - )} - </div> - </TabsContent_Shadcn_> - </Tabs_Shadcn_> - </div> - ) -} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index a820803905d..b8e92898f03 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -19,7 +19,7 @@ import { TabsList_Shadcn_, TabsTrigger_Shadcn_, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx b/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx index c20c5b76342..a2f1fde9b21 100644 --- a/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx @@ -1,27 +1,15 @@ -import Editor from '@monaco-editor/react' import { Check, Copy } from 'lucide-react' import { useMemo, useState } from 'react' import { Button, cn, copyToClipboard } from 'ui' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' + type SqlMonacoBlockProps = { value?: string - className?: string wrapperClassName?: string - hideCopy?: boolean - // Fixed height in px. Defaults to 310 to match previous CodeBlock max height - height?: number - // Show line numbers. Defaults to false to match previous CodeBlock - lineNumbers?: 'on' | 'off' } -export const SqlMonacoBlock = ({ - value, - className, - wrapperClassName, - height = 310, - lineNumbers = 'off', - hideCopy = false, -}: SqlMonacoBlockProps) => { +export const SqlMonacoBlock = ({ value, wrapperClassName }: SqlMonacoBlockProps) => { const [copied, setCopied] = useState(false) const content = useMemo(() => value ?? '', [value]) @@ -36,52 +24,24 @@ export const SqlMonacoBlock = ({ <div className={cn('group relative border rounded-md overflow-hidden w-full', wrapperClassName)} > - <Editor - theme="supabase" + <CodeEditor + hideLineNumbers language="pgsql" value={content} - height={height} - className={className} - wrapperProps={{ - className: - '[&_.monaco-editor]:bg-transparent! [&_.monaco-editor-background]:bg-transparent! [&_.monaco-editor]:outline-transparent! [&_.cursor]:hidden!', - }} - options={{ - readOnly: true, - domReadOnly: true, - fontSize: 13, - minimap: { enabled: false }, - lineNumbers, - renderLineHighlight: 'none', - scrollbar: { vertical: 'auto', horizontal: 'auto' }, - overviewRulerLanes: 0, - overviewRulerBorder: false, - glyphMargin: false, - folding: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: lineNumbers === 'off' ? 0 : 3, - wordWrap: 'on', - scrollBeyondLastLine: false, - selectionHighlight: false, - occurrencesHighlight: 'off', - fixedOverflowWidgets: true, - padding: { top: 12, bottom: 12 }, - tabIndex: -1, - }} + className="h-[322px] pl-2" + options={{ padding: { top: 12, bottom: 12 } }} /> - {!hideCopy && ( - <div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"> - <Button - variant="default" - className="px-1.5" - icon={copied ? <Check /> : <Copy />} - onClick={() => handleCopy(content)} - > - {copied ? 'Copied' : ''} - </Button> - </div> - )} + <div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"> + <Button + variant="default" + className="px-1.5" + icon={copied ? <Check /> : <Copy />} + onClick={() => handleCopy(content)} + > + {copied ? 'Copied' : ''} + </Button> + </div> </div> ) } diff --git a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx index 5c3b74add75..7d683a2bc7b 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx @@ -5,7 +5,7 @@ import { parseAsString, useQueryStates } from 'nuqs' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Button, cn, LoadingLine } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { Markdown } from '../../Markdown' diff --git a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx index 47e41b19c48..8bb5af2b552 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx @@ -6,14 +6,14 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import DataGrid, { Row } from 'react-data-grid' import { Button, cn, IconBroadcast, IconDatabaseChanges, IconPresence } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import type { LogData } from './Messages.types' import MessageSelection from './MessageSelection' import NoChannelEmptyState from './NoChannelEmptyState' import { ColumnRenderer } from './RealtimeMessageColumnRenderer' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import ShimmerLine from '@/components/ui/ShimmerLine' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx index 5887ed4d769..88fbedf87ab 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/FilterSchema.tsx @@ -1,4 +1,4 @@ -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' export const FilterSchema = ({ value, diff --git a/apps/studio/components/interfaces/Realtime/Policies.tsx b/apps/studio/components/interfaces/Realtime/Policies.tsx index 770504521e3..fc407c3652d 100644 --- a/apps/studio/components/interfaces/Realtime/Policies.tsx +++ b/apps/studio/components/interfaces/Realtime/Policies.tsx @@ -1,11 +1,11 @@ import { useMemo, useState } from 'react' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { Policies } from '@/components/interfaces/Auth/Policies/Policies' -import { PoliciesDataProvider } from '@/components/interfaces/Auth/Policies/PoliciesDataContext' -import { PolicyEditorPanel } from '@/components/interfaces/Auth/Policies/PolicyEditorPanel' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import AlertError from '@/components/ui/AlertError' +import { Policies } from '@/components/interfaces/Database/Policies/Policies' +import { PoliciesDataProvider } from '@/components/interfaces/Database/Policies/PoliciesDataContext' +import { PolicyEditorPanel } from '@/components/interfaces/Database/Policies/PolicyEditorPanel' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import { AlertError } from '@/components/ui/AlertError' import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' import { useTablesQuery } from '@/data/tables/tables-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index 7715d1fc869..cccf528fa35 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -20,12 +20,12 @@ import { InputGroupText, Switch, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ToggleSpendCapButton } from '@/components/ui/ToggleSpendCapButton' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' diff --git a/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx index 796c5b8500c..f11b028ad1f 100644 --- a/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx +++ b/apps/studio/components/interfaces/RedeemCredits/RedeemCredits.tsx @@ -2,7 +2,8 @@ import { FeatureFlagContext } from 'common' import { useRouter } from 'next/router' import { ReactNode, useContext, useEffect, useRef, useState } from 'react' import { Button, Card, CardContent } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { OrganizationSelector } from '../Connect/OrganizationSelector' import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption' diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx index 6dbc5385b98..1ae3a09d31b 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx @@ -19,7 +19,7 @@ import { formatYAxisTick, } from '@/components/ui/QueryBlock/QueryBlock.utils' import { AnalyticsInterval } from '@/data/analytics/constants' -import { mapMultiResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries' +import { mapResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries' import { InfraMonitoringAttribute, useInfraMonitoringAttributesQuery, @@ -119,7 +119,7 @@ export const ChartBlock = ({ const infraMonitoringData = useMemo(() => { if (!infraMonitoringRawData) return undefined - const mapped = mapMultiResponseToAnalyticsData(infraMonitoringRawData, [ + const mapped = mapResponseToAnalyticsData(infraMonitoringRawData, [ attribute as InfraMonitoringAttribute, ]) return mapped[attribute] @@ -153,19 +153,19 @@ export const ChartBlock = ({ const getCellColor = (attribute: string, value: number) => { const threshold = METRIC_THRESHOLDS[attribute as keyof typeof METRIC_THRESHOLDS] - if (!threshold) return 'hsl(var(--chart-1))' + if (!threshold) return 'var(--chart-1)' if (threshold.check === 'gt') { return value >= threshold.danger - ? 'hsl(var(--chart-destructive))' + ? 'var(--chart-destructive)' : value >= threshold.warning - ? 'hsl(var(--chart-warning))' - : 'hsl(var(--chart-1))' + ? 'var(--chart-warning)' + : 'var(--chart-1)' } else { return value <= threshold.danger - ? 'hsl(var(--chart-destructive))' + ? 'var(--chart-destructive)' : value <= threshold.warning - ? 'hsl(var(--chart-warning))' - : 'hsl(var(--chart-1))' + ? 'var(--chart-warning)' + : 'var(--chart-1)' } } @@ -362,7 +362,7 @@ export const ChartBlock = ({ /> } /> - <Line dataKey={metricLabel} stroke="hsl(var(--chart-1))" radius={4} /> + <Line dataKey={metricLabel} stroke="var(--chart-1)" radius={4} /> </LineChart> )} </ChartContainer> diff --git a/apps/studio/components/interfaces/Reports/ReportFilterPopover.tsx b/apps/studio/components/interfaces/Reports/ReportFilterPopover.tsx deleted file mode 100644 index cf658389bac..00000000000 --- a/apps/studio/components/interfaces/Reports/ReportFilterPopover.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import { isEqual } from 'lodash' -import { ChevronDown, Filter as FilterIcon, Plus, X } from 'lucide-react' -import { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' -import { - Button, - cn, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - Input, - Popover, - PopoverContent, - PopoverSeparator, - PopoverTrigger, -} from 'ui' -import { sizes } from 'ui/src/lib/commonCva' -import defaultTheme from 'ui/src/lib/theme/defaultTheme' - -import type { ReportFilter, ReportFilterProperty } from './Reports.types' -import { DropdownControl } from '@/components/grid/components/common/DropdownControl' - -const FilterableInput = ({ - value, - onChange, - onKeyDown, - placeholder, - availableOptions = [], -}: { - value: string | number - onChange: (value: string | number) => void - onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void - placeholder?: string - type?: string - availableOptions?: string[] -}) => { - const [isOpen, setIsOpen] = useState(false) - const [inputValue, setInputValue] = useState(String(value || '')) - - // Ensure we always have a valid array - const safeOptions = Array.isArray(availableOptions) ? availableOptions : [] - - useEffect(() => { - setInputValue(String(value || '')) - }, [value]) - - const handleInputChange = (newValue: string) => { - setInputValue(newValue) - onChange(newValue) - } - - const handleOptionSelect = (option: string) => { - setInputValue(option) - onChange(option) - setIsOpen(false) - } - - const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { - if (event.key === 'Escape') { - setIsOpen(false) - return - } - - // When dropdown is open, let Command component handle Enter for selection - if (event.key === 'Enter' && isOpen) { - // Don't call onKeyDown to prevent interference with Command's selection - return - } - - // When dropdown is closed, allow custom input - if (event.key === 'Enter' && !isOpen) { - return - } - - onKeyDown(event) - } - - return ( - <div className="relative flex-1"> - <Command className="relative overflow-visible bg-transparent"> - <CommandInput - placeholder={placeholder} - value={inputValue} - onValueChange={handleInputChange} - onFocus={() => setIsOpen(true)} - onKeyDown={handleKeyDown} - className={cn('h-6 text-sm', defaultTheme.input.variants.standard, sizes.tiny)} - wrapperClassName="p-0! border! border-control! rounded-md" - showSearchIcon={false} - /> - <div - className={cn( - 'absolute top-full left-0 right-0 z-50 mt-1 opacity-0 transition-opacity pointer-events-none', - isOpen && safeOptions.length > 0 && 'opacity-100 pointer-events-auto' - )} - > - <CommandList className="max-h-60 overflow-auto bg-surface-100 border border-border rounded-md shadow-lg"> - <CommandEmpty className="py-2 px-3 text-sm text-foreground-lighter"> - No matching options found. Press Enter to use "{inputValue}" - </CommandEmpty> - <CommandGroup> - {safeOptions.map((option, index) => ( - <CommandItem - key={`${option}-${index}`} - value={option} - onSelect={() => handleOptionSelect(option)} - className="px-3 py-2 text-sm cursor-pointer" - > - {option} - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </div> - </Command> - {isOpen && <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />} - </div> - ) -} - -export interface ReportFilterPopoverProps { - buttonText?: string - filters: ReportFilter[] - filterProperties: ReportFilterProperty[] - onFiltersChange: (filters: ReportFilter[]) => void - disabled?: boolean -} - -const ReportFilterRow = ({ - filter, - filterIdx, - filterProperties, - onChange, - onDelete, - onKeyDown, -}: { - filter: ReportFilter - filterIdx: number - filterProperties: ReportFilterProperty[] - onChange: (index: number, filter: ReportFilter) => void - onDelete: (index: number) => void - onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void -}) => { - const property = filterProperties.find((p) => p.name === filter.propertyName) - - const propertyOptions = filterProperties.map((prop) => ({ - value: prop.name, - label: prop.label, - postLabel: prop.type, - })) - - const operatorOptions = - property?.operators.map((op) => ({ - value: op, - label: op, - })) || [] - - const valueOptions = property?.options || [] - - const handlePropertyChange = (newPropertyName: string | number) => { - const newProperty = filterProperties.find((p) => p.name === newPropertyName) - if (newProperty) { - onChange(filterIdx, { - propertyName: newPropertyName, - operator: newProperty.operators[0] || '=', - value: '', - }) - } - } - - const handleOperatorChange = (newOperator: string | number) => { - onChange(filterIdx, { - ...filter, - operator: newOperator, - }) - } - - const handleValueChange = (value: string | number) => { - onChange(filterIdx, { - ...filter, - value, - }) - } - - return ( - <div className="flex flex-col px-3"> - {filterIdx > 0 && ( - <div className="flex items-center gap-2 p-1 w-full"> - <div className="w-auto flex-1 bg-border h-px" /> - <span className="text-xs font-mono text-foreground-lighter">AND</span> - <div className="w-auto flex-1 bg-border h-px" /> - </div> - )} - <div className="flex w-full items-center justify-between gap-x-2"> - <DropdownControl align="start" options={propertyOptions} onSelect={handlePropertyChange}> - <Button - asChild - variant="outline" - icon={ - <div className="text-foreground-lighter"> - <ChevronDown strokeWidth={1.5} /> - </div> - } - className="w-32 justify-start" - > - <span>{property?.label ?? 'Select property'}</span> - </Button> - </DropdownControl> - - <DropdownControl align="start" options={operatorOptions} onSelect={handleOperatorChange}> - <Button - asChild - variant="outline" - icon={ - <div className="text-foreground-lighter"> - <ChevronDown strokeWidth={1.5} /> - </div> - } - className="w-20 justify-start" - > - <span>{filter.operator}</span> - </Button> - </DropdownControl> - - {valueOptions?.length > 0 ? ( - <FilterableInput - value={filter.value} - onChange={handleValueChange} - onKeyDown={onKeyDown} - placeholder={ - property?.placeholder || `Enter ${property?.type === 'number' ? 'number' : 'text'}` - } - availableOptions={valueOptions?.map((option) => String(option.value)) || []} - /> - ) : ( - <Input - size="tiny" - className="flex-1" - placeholder={ - property?.placeholder || `Enter ${property?.type === 'number' ? 'number' : 'text'}` - } - value={filter.value} - type={property?.type === 'number' ? 'number' : 'text'} - onChange={(event) => handleValueChange(event.target.value)} - onKeyDown={onKeyDown} - /> - )} - <Button - variant="text" - size="tiny" - className="px-1" - icon={<X strokeWidth={1.5} />} - onClick={() => onDelete(filterIdx)} - /> - </div> - </div> - ) -} - -export const ReportFilterPopover = ({ - buttonText, - filters, - filterProperties, - onFiltersChange, - disabled = false, -}: ReportFilterPopoverProps) => { - const [open, setOpen] = useState(false) - const [localFilters, setLocalFilters] = useState<ReportFilter[]>(filters) - - // Update local state when filters prop changes - useMemo(() => { - setLocalFilters(filters) - }, [filters]) - - const displayButtonText = - buttonText ?? - (filters.length > 0 - ? `Filtered by ${filters.length} rule${filters.length > 1 ? 's' : ''}` - : 'Filter') - - const onAddFilter = () => { - const firstProperty = filterProperties[0] - if (firstProperty) { - setLocalFilters([ - ...localFilters, - { - propertyName: firstProperty.name, - operator: firstProperty.operators[0] || '=', - value: '', - }, - ]) - } - } - - const onChangeFilter = useCallback((index: number, filter: ReportFilter) => { - setLocalFilters((currentFilters) => [ - ...currentFilters.slice(0, index), - filter, - ...currentFilters.slice(index + 1), - ]) - }, []) - - const onDeleteFilter = useCallback((index: number) => { - setLocalFilters((currentFilters) => [ - ...currentFilters.slice(0, index), - ...currentFilters.slice(index + 1), - ]) - }, []) - - const onApplyFilters = () => { - // Filter out empty values - const validFilters = localFilters.filter( - (f) => f.value !== null && f.value !== undefined && f.value !== '' - ) - onFiltersChange(validFilters) - setOpen(false) - } - - const onResetFilters = () => { - setLocalFilters([]) - onFiltersChange([]) - setOpen(false) - } - - function handleEnterKeyDown(event: KeyboardEvent<HTMLInputElement>) { - if (event.key === 'Enter') onApplyFilters() - } - - const hasChanges = !isEqual(localFilters, filters) - - return ( - <Popover open={open} onOpenChange={setOpen} modal={false}> - <PopoverTrigger asChild> - <Button - variant={filters.length > 0 ? 'link' : 'text'} - icon={<FilterIcon />} - disabled={disabled} - > - {displayButtonText} - </Button> - </PopoverTrigger> - <PopoverContent className="p-0 w-[500px]" side="bottom" align="start"> - <div className="space-y-2 py-2"> - <div> - {localFilters.map((filter, index) => ( - <ReportFilterRow - key={`filter-${filter.propertyName}-${index}`} - filter={filter} - filterIdx={index} - filterProperties={filterProperties} - onChange={onChangeFilter} - onDelete={onDeleteFilter} - onKeyDown={handleEnterKeyDown} - /> - ))} - {localFilters.length === 0 && ( - <div className="space-y-1 px-3"> - <h5 className="text-sm text-foreground-light">No filters applied</h5> - <p className="text-xs text-foreground-lighter"> - Add a filter below to narrow down the report data - </p> - </div> - )} - </div> - <PopoverSeparator /> - <div className="px-3 flex flex-row justify-between"> - <div className="flex gap-2"> - <Button icon={<Plus />} variant="text" onClick={onAddFilter}> - Add filter - </Button> - {filters.length > 0 && ( - <Button variant="text" onClick={onResetFilters}> - Clear all - </Button> - )} - </div> - <Button disabled={!hasChanges} variant="default" onClick={onApplyFilters}> - Apply filters - </Button> - </div> - </div> - </PopoverContent> - </Popover> - ) -} diff --git a/apps/studio/components/interfaces/Reports/ReportStickyNav.tsx b/apps/studio/components/interfaces/Reports/ReportStickyNav.tsx index 81e8ecf1505..984202b13f8 100644 --- a/apps/studio/components/interfaces/Reports/ReportStickyNav.tsx +++ b/apps/studio/components/interfaces/Reports/ReportStickyNav.tsx @@ -9,7 +9,7 @@ const ReportStickyNav = ({ return ( <section className={cn('relative flex flex-col gap-4 pt-16 -mt-2', className)}> <div className="absolute inset-0 z-40 pointer-events-none flex flex-col gap-4"> - <div className="sticky top-0 py-4 mb-4 flex items-center gap-2 pointer-events-auto dark:bg-background-200 bg-background"> + <div className="sticky top-0 py-4 mb-4 flex w-full items-center gap-2 pointer-events-auto bg-background"> {content} </div> </div> diff --git a/apps/studio/components/interfaces/Reports/Reports.tsx b/apps/studio/components/interfaces/Reports/Reports.tsx index 9ee6bf22a91..347741a16d7 100644 --- a/apps/studio/components/interfaces/Reports/Reports.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.tsx @@ -12,11 +12,13 @@ import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig' import { GridResize } from './GridResize' import { MetricOptions } from './MetricOptions' import { LAYOUT_COLUMN_COUNT } from './Reports.constants' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DatabaseSelector } from '@/components/ui/DatabaseSelector' import { DateRangePicker } from '@/components/ui/DateRangePicker' -import NoPermission from '@/components/ui/NoPermission' +import { DocsButton } from '@/components/ui/DocsButton' +import { NoPermission } from '@/components/ui/NoPermission' import { DEFAULT_CHART_CONFIG } from '@/components/ui/QueryBlock/QueryBlock' import { AnalyticsInterval } from '@/data/analytics/constants' import { analyticsKeys } from '@/data/analytics/keys' @@ -366,12 +368,14 @@ const Reports = () => { return <NoPermission isFullPage resourceText="access this custom report" /> } + const reportTitle = currentReport?.name || 'Reports' + return ( <> <div className="flex flex-col space-y-4" style={{ maxHeight: '100%' }}> <div className="flex items-center justify-between"> <div> - <h1>{currentReport?.name || 'Reports'}</h1> + <h1>{reportTitle}</h1> <p className="text-foreground-light">{currentReport?.description}</p> </div> {hasEdits && ( @@ -396,14 +400,6 @@ const Reports = () => { </div> <div className={cn('mb-4 flex items-center gap-x-3 justify-between')}> <div className="flex items-center gap-x-2"> - <ButtonTooltip - variant="default" - icon={<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />} - className="w-7" - disabled={isRefreshing} - tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} - onClick={onRefreshReport} - /> <div className="flex items-center gap-x-3"> <DateRangePicker value="7d" @@ -423,6 +419,15 @@ const Reports = () => { </div> <div className="flex items-center gap-x-2"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.customReport} topic={reportTitle} /> + <ButtonTooltip + variant="default" + icon={<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />} + className="w-7" + disabled={isRefreshing} + tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} + onClick={onRefreshReport} + /> {canUpdateReport ? ( <DropdownMenu> <DropdownMenuTrigger asChild> diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.tsx b/apps/studio/components/interfaces/Reports/Reports.utils.tsx index f27ee797ef5..f7f58c41a15 100644 --- a/apps/studio/components/interfaces/Reports/Reports.utils.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.utils.tsx @@ -13,7 +13,7 @@ import { import type { SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' import { REPORT_STATUS_CODE_COLORS } from '@/data/reports/report.utils' import useDbQuery, { DbQueryHook } from '@/hooks/analytics/useDbQuery' -import useLogsQuery, { LogsQueryHook } from '@/hooks/analytics/useLogsQuery' +import { useLogsQuery, type LogsQueryHook } from '@/hooks/analytics/useLogsQuery' import { getHttpStatusCodeInfo } from '@/lib/http-status-codes' /** diff --git a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index c794a1d0116..eacfb0f3d27 100644 --- a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -35,7 +35,7 @@ import { TextFormatter, } from '@/components/interfaces/Settings/Logs/LogsFormatters' import Table from '@/components/to-be-cleaned/Table' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import BarChart from '@/components/ui/Charts/BarChart' import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' import { useFillTimeseriesSorted } from '@/hooks/analytics/useFillTimeseriesSorted' diff --git a/apps/studio/components/interfaces/Reports/useReportFilters.ts b/apps/studio/components/interfaces/Reports/useReportFilters.ts deleted file mode 100644 index a913df57453..00000000000 --- a/apps/studio/components/interfaces/Reports/useReportFilters.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { parseAsString, useQueryStates } from 'nuqs' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import type { ReportFilter, ReportFilterItem, ReportFilterProperty } from './Reports.types' - -export enum ReportFilterKeys { - PATH = 'request.path', - SEARCH = 'request.search', - X_CLIENT_INFO = 'request.headers.x_client_info', - USER_AGENT = 'request.headers.user_agent', - STATUS_CODE = 'response.status_code', -} - -export const REPORT_FILTER_PARAMS_PARSER = { - [ReportFilterKeys.PATH]: parseAsString, - [ReportFilterKeys.SEARCH]: parseAsString, - [ReportFilterKeys.X_CLIENT_INFO]: parseAsString, - [ReportFilterKeys.USER_AGENT]: parseAsString, - [ReportFilterKeys.STATUS_CODE]: parseAsString, -} - -interface UseReportFiltersProps { - onAddFilter: (filter: ReportFilterItem) => void - onRemoveFilters: (filters: ReportFilterItem[]) => void - filters: ReportFilterItem[] -} - -export const useReportFilters = ({ - onAddFilter, - onRemoveFilters, - filters, -}: UseReportFiltersProps) => { - // URL-safe operator mappings - const URL_OPERATOR_MAP = { - '=': 'eq', - '!=': 'neq', - '>=': 'gte', - '<=': 'lte', - '>': 'gt', - '<': 'lt', - CONTAINS: 'contains', - 'STARTS WITH': 'startswith', - 'ENDS WITH': 'endswith', - } - - const REVERSE_URL_OPERATOR_MAP = Object.fromEntries( - Object.entries(URL_OPERATOR_MAP).map(([k, v]) => [v, k]) - ) - - // Parse encoded filter value (e.g., "gte:300" -> { operator: ">=", value: "300" }) - const parseFilterValue = useCallback((encodedValue: string | number | null) => { - if (!encodedValue || typeof encodedValue !== 'string') { - return { operator: '=', value: encodedValue?.toString() || '' } - } - - const parts = encodedValue.split(':') - if (parts.length === 2) { - const [urlOperator, value] = parts - const operator = REVERSE_URL_OPERATOR_MAP[urlOperator] || '=' - return { operator, value } - } - - return { operator: '=', value: encodedValue } - }, []) - - const encodeFilterValue = useCallback((operator: string | number, value: string | number) => { - const urlOperator = URL_OPERATOR_MAP[operator as keyof typeof URL_OPERATOR_MAP] || 'eq' - return `${urlOperator}:${value}` - }, []) - - // Convert query filters to ReportFilter format - const convertQueryFiltersToReportFilters = useCallback( - (queryState: Record<string, string | number | null>): ReportFilter[] => { - const filters: ReportFilter[] = [] - - Object.entries(queryState).forEach(([key, value]) => { - if (value !== null && value !== undefined && value !== '') { - const { operator, value: filterValue } = parseFilterValue(value) - filters.push({ - propertyName: key, - value: filterValue, - operator: operator, - }) - } - }) - - return filters - }, - [parseFilterValue] - ) - - // Convert operator from FilterBar format to ReportFilterItem format - const getCompareOperator = useCallback( - (operator: string | number): 'matches' | 'is' | '>=' | '<=' | '>' | '<' | '!=' => { - switch (operator) { - case '=': - return 'is' - case '!=': - return '!=' - case '>=': - return '>=' - case '<=': - return '<=' - case '>': - return '>' - case '<': - return '<' - case 'CONTAINS': - case 'STARTS WITH': - case 'ENDS WITH': - return 'matches' - default: - return 'matches' - } - }, - [] - ) - - // Convert ReportFilter to ReportFilterItem format for the report system - const convertReportFiltersToReportFilterItems = useMemo( - () => - (reportFilters: ReportFilter[]): ReportFilterItem[] => { - const reportFilterItems: ReportFilterItem[] = [] - - reportFilters.forEach((filter) => { - if (filter.value !== null && filter.value !== '' && filter.value !== undefined) { - reportFilterItems.push({ - key: filter.propertyName.toString(), - value: filter.value.toString(), - compare: getCompareOperator(filter.operator), - }) - } - }) - - return reportFilterItems - }, - [getCompareOperator] - ) - - // Convert ReportFilter back to query filters format - const convertReportFiltersToQueryFilters = useMemo( - () => (reportFilters: ReportFilter[]) => { - const queryUpdate: Record<string, string | number | null> = {} - - // Clear all existing filters first - Object.keys(REPORT_FILTER_PARAMS_PARSER).forEach((key) => { - queryUpdate[key] = null - }) - - // Add new filters with encoded operator+value - reportFilters.forEach((filter) => { - if (filter.propertyName in REPORT_FILTER_PARAMS_PARSER) { - const encodedValue = encodeFilterValue(filter.operator, filter.value as string | number) - queryUpdate[filter.propertyName] = encodedValue - } - }) - - return queryUpdate - }, - [encodeFilterValue] - ) - - const [queryFilters, setQueryFilters] = useQueryStates(REPORT_FILTER_PARAMS_PARSER) - const [localFilters, setLocalFilters] = useState<ReportFilter[]>([]) - - const [isInitialized, setIsInitialized] = useState(false) - const lastAppliedFilterState = useRef<string>('') - const preventUrlSync = useRef(false) - - // Initialize local state from URL params (only once on mount) - useEffect(() => { - if (!isInitialized) { - const initialFilters = convertQueryFiltersToReportFilters(queryFilters) - setLocalFilters(initialFilters) - setIsInitialized(true) - } - }, [isInitialized, convertQueryFiltersToReportFilters, queryFilters]) - - // Sync URL changes back to local state (when URL changes externally) - useEffect(() => { - if (!isInitialized || preventUrlSync.current) return - - const filtersFromUrl = convertQueryFiltersToReportFilters(queryFilters) - const currentFilterJson = JSON.stringify(localFilters) - const urlFilterJson = JSON.stringify(filtersFromUrl) - - if (currentFilterJson !== urlFilterJson) { - console.log('Syncing local filters from URL change:', { filtersFromUrl, localFilters }) - setLocalFilters(filtersFromUrl) - } - }, [queryFilters, isInitialized, convertQueryFiltersToReportFilters]) - - // Sync local filter changes back to URL state (only after initialization) - useEffect(() => { - if (!isInitialized) return - - const queryUpdate = convertReportFiltersToQueryFilters(localFilters) - - // Prevent feedback loop during URL updates - preventUrlSync.current = true - setQueryFilters(queryUpdate).finally(() => { - preventUrlSync.current = false - }) - }, [localFilters, isInitialized, setQueryFilters, convertReportFiltersToQueryFilters]) - - // Separate effect for report filter updates (only for filters with values) - useEffect(() => { - if (!isInitialized) return - - const reportFilterItems = convertReportFiltersToReportFilterItems(localFilters) - - // Create a state string for comparison (only for the filters we manage) - const newFilterState = reportFilterItems - .map((filter) => `${filter.key}:${filter.value}:${filter.compare}`) - .sort() - .join('|') - - if (lastAppliedFilterState.current !== newFilterState) { - lastAppliedFilterState.current = newFilterState - - // Separate product filters (managed by dropdown) from other filters (managed by ReportFilterPopover) - const PRODUCT_FILTER_KEYS = ['request.path'] - const PRODUCT_FILTER_VALUES = ['/rest', '/auth', '/storage', '/realtime', '/graphql'] - - const productFilters = filters.filter( - (f) => - PRODUCT_FILTER_KEYS.includes(f.key) && PRODUCT_FILTER_VALUES.includes(f.value.toString()) - ) - const otherFilters = filters.filter( - (f) => - !( - PRODUCT_FILTER_KEYS.includes(f.key) && - PRODUCT_FILTER_VALUES.includes(f.value.toString()) - ) - ) - - console.log('Filter Update:', { - localFilters, - reportFilterItems, - productFilters, - otherFilters, - newFilterState, - }) - - // Remove only the non-product filters that we manage - if (otherFilters.length > 0) { - onRemoveFilters(otherFilters) - } - - // Add all the new filters from the popover - reportFilterItems.forEach((filter) => onAddFilter(filter)) - } - }, [localFilters, isInitialized]) - - const handleFilterChange = (newFilters: ReportFilter[]) => { - console.log('handleFilterChange called with:', newFilters) - setLocalFilters(newFilters) - } - - // Get filter properties based on product type - const getFilterProperties = (): ReportFilterProperty[] => { - const baseProperties: ReportFilterProperty[] = [ - { - label: 'Status Code', - name: ReportFilterKeys.STATUS_CODE, - type: 'number' as const, - options: [ - { label: '200', value: '200' }, - { label: '300', value: '300' }, - { label: '400', value: '400' }, - { label: '401', value: '401' }, - { label: '403', value: '403' }, - { label: '404', value: '404' }, - { label: '409', value: '409' }, - { label: '411', value: '411' }, - { label: '413', value: '413' }, - { label: '416', value: '416' }, - { label: '423', value: '423' }, - { label: '500', value: '500' }, - { label: '503', value: '503' }, - { label: '504', value: '504' }, - ], - operators: ['=', '!=', '>', '<', '>=', '<='], - placeholder: '200', - }, - { - label: 'User Agent', - name: ReportFilterKeys.USER_AGENT, - type: 'string' as const, - operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'], - placeholder: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', - }, - { - label: 'Search Params', - name: ReportFilterKeys.SEARCH, - type: 'string' as const, - operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'], - placeholder: '?foo=bar', - }, - { - label: 'Client Info', - name: ReportFilterKeys.X_CLIENT_INFO, - type: 'string' as const, - operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'], - placeholder: 'supabase-js/1.0.0', - }, - ] - - return [ - { - label: 'Path', - name: ReportFilterKeys.PATH, - type: 'string' as const, - operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'], - placeholder: '/rest/v1/tablename', - }, - ...baseProperties, - ] - } - - return { - localFilters, - filterProperties: getFilterProperties(), - handleFilterChange, - ReportFilterKeys, - } -} diff --git a/apps/studio/components/interfaces/Reports/utils/geo.ts b/apps/studio/components/interfaces/Reports/utils/geo.ts index bb20edebdf8..af83306166b 100644 --- a/apps/studio/components/interfaces/Reports/utils/geo.ts +++ b/apps/studio/components/interfaces/Reports/utils/geo.ts @@ -15,19 +15,19 @@ export interface MapChartTheme { export const MAP_CHART_THEME: { light: MapChartTheme; dark: MapChartTheme } = { light: { - zeroFill: 'hsl(var(--background-surface-400))', + zeroFill: 'var(--background-surface-400)', brandFill: 'hsl(var(--brand-default))', opacityScale: [0.18, 0.32, 0.5, 0.68, 0.86], - boundaryStroke: 'hsla(var(--brand-300), 0.6)', + boundaryStroke: 'hsl(var(--brand-300) / 0.6)', boundaryStrokeHover: 'hsl(var(--brand-500))', markerFill: 'hsl(var(--brand-default))', oceanFill: 'transparent', }, dark: { - zeroFill: 'hsl(var(--background-selection))', + zeroFill: 'var(--background-selection)', brandFill: 'hsl(var(--brand-default))', opacityScale: [0.18, 0.32, 0.5, 0.68, 0.86], - boundaryStroke: 'hsla(var(--brand-300), 0.6)', + boundaryStroke: 'hsl(var(--brand-300) / 0.6)', boundaryStrokeHover: 'hsl(var(--brand-500))', markerFill: 'hsl(var(--brand-default))', oceanFill: 'transparent', diff --git a/apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx b/apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx index 3e8737fa5f6..b7deb03170e 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx @@ -83,7 +83,7 @@ export const ReportsSelectFilter = ({ <PopoverContent align="start" className="p-0 w-72"> <Command> {showSearch && <CommandInput placeholder="Search..." />} - <CommandList> + <CommandList className="max-h-72"> <CommandEmpty>No options found.</CommandEmpty> <CommandGroup> {options.map((option) => ( diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx index 496684d689d..8b6240caf8a 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx @@ -27,7 +27,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query' import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTokenHookDetails' @@ -235,7 +235,7 @@ export const UserImpersonationSelector = () => { <InputGroup> <InputGroupInput size="tiny" - className="table-editor-search border-none" + className="pr-10 border-none" placeholder="Search by id, email, phone, or name..." onChange={(e) => setSearchText(e.target.value)} value={searchText} diff --git a/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx b/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx index b6f809dd178..911791fc25e 100644 --- a/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx @@ -27,7 +27,7 @@ import { } from './SQLEditor.utils' import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle' import { DOCS_URL } from '@/lib/constants' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' const CLI_DOCS_URL = `${DOCS_URL}/guides/cli/local-development` diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index dc954c5088a..07f140c3bbc 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -1,26 +1,20 @@ import { Monaco, OnMount } from '@monaco-editor/react' -import { useDebounce } from '@uidotdev/usehooks' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { LOCAL_STORAGE_KEYS } from 'common' import { noop } from 'lodash' -import { useRouter } from 'next/router' -import { RefObject, useEffect, useRef, useState } from 'react' -import { Admonition } from 'ui-patterns' +import { RefObject, useRef } from 'react' +import { Admonition } from 'ui-patterns/admonition' import type { IStandaloneCodeEditor } from './SQLEditor.types' -import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils' +import { useSnippetEditor } from './useSnippetEditor' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { getEditorSelectionParts } from '@/components/ui/AIEditor/utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { useProfile } from '@/lib/profile' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' -import { canEditSnippet } from '@/state/sql-editor/sql-editor-rules' -import { useTabsStateSnapshot } from '@/state/tabs' +import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator' export type MonacoEditorProps = { id: string @@ -61,13 +55,6 @@ export const MonacoEditor = ({ onPrompt, onMount, }: MonacoEditorProps) => { - const router = useRouter() - const { profile } = useProfile() - const { ref, content } = useParams() - const { data: project } = useSelectedProjectQuery() - - const snapV2 = useSqlEditorV2StateSnapshot() - const tabsSnap = useTabsStateSnapshot() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar, toggleSidebar } = useSidebarManagerSnapshot() @@ -76,11 +63,14 @@ export const MonacoEditor = ({ true ) - const [value, setValue] = useState('') - const debouncedValue = useDebounce(value, 1000) + const { snippet, disableEdit, handleEditorChange } = useSnippetEditor({ id, snippetName }) - const snippet = snapV2.snippets[id] - const disableEdit = !!snippet && !canEditSnippet(snippet.snippet, profile?.id) + // The Monaco save action is registered once on mount, but `snippet` starts + // undefined for a new/deep-linked snippet and is only created on first edit. + // Read it through a ref so Cmd/Ctrl+S sees the latest value, not the stale + // mount-time closure. + const snippetRef = useRef(snippet) + snippetRef.current = snippet const executeExplainQueryRef = useRef(executeExplainQuery) executeExplainQueryRef.current = executeExplainQuery @@ -92,6 +82,10 @@ export const MonacoEditor = ({ const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled) aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled + const { requestSave } = useSqlEditorSaveCoordinator() + const requestSaveRef = useRef(requestSave) + requestSaveRef.current = requestSave + const handleEditorOnMount: OnMount = (editor, monaco) => { const model = editor.getModel() if (model !== null) { @@ -141,7 +135,8 @@ export const MonacoEditor = ({ contextMenuGroupId: 'operation', contextMenuOrder: 0, run: () => { - if (snippet) snapV2.addNeedsSaving(snippet.snippet.id) + const currentSnippet = snippetRef.current + if (currentSnippet) requestSaveRef.current(currentSnippet.snippet.id) }, }) @@ -209,46 +204,6 @@ export const MonacoEditor = ({ onMount?.(editor) } - function handleEditorChange(value: string | undefined) { - tabsSnap.makeActiveTabPermanent() - if (id && value) { - if (!snippet && ref && profile !== undefined && project !== undefined) { - const snippet = createSqlSnippetSkeletonV2({ - idOverride: id, - name: snippetName, - sql: value, - owner_id: profile?.id, - project_id: project?.id, - }) - snapV2.addSnippet({ projectRef: ref, snippet }) - // When the editor was seeded from a `content` deep-link, replace rather - // than push. The caller navigated to `/sql/new?content=...` (a long, - // one-shot URL); replacing collapses it out of history so Back returns to - // the originating page instead of a wasted step that re-seeds the snippet. - if (router.query.content !== undefined) { - router.replace(`/project/${ref}/sql/${snippet.id}`, undefined, { shallow: true }) - } else { - router.push(`/project/${ref}/sql/${snippet.id}`, undefined, { shallow: true }) - } - } - setValue(value) - } - } - - useEffect(() => { - if (debouncedValue.length > 0 && snippet) { - const shouldInvalidate = snippet.snippet.isNotSavedInDatabaseYet - snapV2.setSql({ id, sql: value, shouldInvalidate }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedValue]) - - // if an SQL query is passed by the content parameter, set the editor value to its content. This - // is usually used for sending the user to SQL editor from other pages with SQL. - useEffect(() => { - if (content && content.length > 0) handleEditorChange(content) - }, []) - return ( <> {disableEdit && ( diff --git a/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx index 7fd3ea7ae7e..bb300f5beed 100644 --- a/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx @@ -41,11 +41,8 @@ import { getContentById } from '@/data/content/content-id-query' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' import { useSQLSnippetFolderCreateMutation } from '@/data/content/sql-folder-create-mutation' import { Snippet } from '@/data/content/sql-folders-query' -import { - SnippetWithContent, - useSnippetFolders, - useSqlEditorV2StateSnapshot, -} from '@/state/sql-editor-v2' +import type { SnippetWithContent } from '@/data/content/sql-folders-query' +import { useSnippetFolders, useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' interface MoveQueryModalProps { diff --git a/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx b/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx index 0d9f17cd499..0dbb575183f 100644 --- a/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx @@ -19,7 +19,7 @@ import { import { CodeBlock } from 'ui-patterns/CodeBlock' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useQueryAbortMutation } from '@/data/sql/abort-query-mutation' import { useOngoingQueriesQuery } from '@/data/sql/ongoing-queries-query' diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index fa5da761acf..525848f81cf 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -26,7 +26,7 @@ import * as z from 'zod' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query' import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation' -import { getContentById } from '@/data/content/content-id-query' +import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query' import { UpsertContentPayload, useContentUpsertMutation, @@ -35,7 +35,7 @@ import { Snippet } from '@/data/content/sql-folders-query' import type { SqlSnippet } from '@/data/content/sql-snippets-query' import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { IS_PLATFORM } from '@/lib/constants' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' export interface RenameQueryModalProps { @@ -109,8 +109,9 @@ export const RenameQueryModal = ({ // [Joshen] For SQL V2 - content is loaded on demand so we need to fetch the data if its not already loaded in the valtio state if (!('content' in localSnippet)) { - localSnippet = await getContentById({ projectRef: ref, id }) - snapV2.addSnippet({ projectRef: ref, snippet: localSnippet }) + const fetched = await getSqlSnippetById({ projectRef: ref, id }) + snapV2.addSnippet({ projectRef: ref, snippet: fetched }) + localSnippet = fetched } const changedSnippet = await upsertContent({ diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.queries.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.queries.ts index 2f42bc16365..bff40b0a25d 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.queries.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.queries.ts @@ -1600,4 +1600,37 @@ grant execute on function public.custom_access_token_hook to supabase_auth_admin revoke execute on function public.custom_access_token_hook from authenticated, anon, public; `, }, + { + id: 30, + type: 'quickstart', + title: 'Instruments', + description: + 'Create an instruments table with sample data and Row Level Security, as used in the framework quickstarts.', + sql: ` +-- Create the table +create table instruments ( + id bigint primary key generated always as identity, + name text not null +); + +-- Insert sample data into the table +insert into instruments (name) +values + ('violin'), + ('viola'), + ('cello'); + +-- Grant the privileges the role needs, which is read access +grant select on public.instruments to anon; + +-- Enable row level security for the table +alter table instruments enable row level security; + +-- Create a policy to allow the anon role to read from the instruments table +create policy "public can read instruments" +on public.instruments +for select to anon +using (true); +`.trim(), + }, ] diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index c3f736f8caf..085cfdf1714 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -12,7 +12,7 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import { ChevronUp, Loader2 } from 'lucide-react' import dynamic from 'next/dynamic' import { useRouter } from 'next/router' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Button, @@ -92,7 +92,12 @@ import { import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorDiffRequestSnapshot } from '@/state/sql-editor/sql-editor-diff-request' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' +import { + getSqlEditorV2StateSnapshot, + useSqlEditorV2StateSnapshot, +} from '@/state/sql-editor/sql-editor-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' // Load the monaco editor client-side only (does not behave well server-side) @@ -119,10 +124,11 @@ export const SQLEditor = () => { const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() + const sessionSnap = useSqlEditorSessionSnapshot() + const diffRequest = useSqlEditorDiffRequestSnapshot() const getImpersonatedRoleState = useGetImpersonatedRoleState() const databaseSelectorState = useDatabaseSelectorStateSnapshot() const { aiOptInLevel } = useOrgAiOptInLevel() - const showPrettyExplain = useFlag('ShowPrettyExplain') // [Ali] Kill switch to hide the SQL Editor Explain tab and its entry points const disablePrettyExplain = useFlag('DisablePrettyExplainOnSqlEditor') @@ -152,6 +158,9 @@ export const SQLEditor = () => { const [potentialIssues, setPotentialIssues] = useState<PotentialIssues>() const [showWidget, setShowWidget] = useState(false) + // Bumped on every editor mount (including the keyed remount on snippet switch) + // so a diff request that arrived before the editor was ready gets re-processed. + const [editorMountCount, setEditorMountCount] = useState(0) const [activeUtilityTab, setActiveUtilityTab] = useState<string>('results') const refocusEditor = useCallback(() => { @@ -197,8 +206,8 @@ export const SQLEditor = () => { // the id is stable across renders - it depends either on the url or on the memoized generated id const id = !urlId || urlId === 'new' ? generatedId : urlId - const limit = snapV2.limit - const results = snapV2.results[id]?.[0] + const limit = sessionSnap.limit + const results = sessionSnap.results[id]?.[0] const snippetIsLoading = !( id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined ) @@ -227,10 +236,10 @@ export const SQLEditor = () => { const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({ onSuccess(data, vars) { if (id) { - snapV2.addResult(id, data.result, vars.autoLimit) + sessionSnap.addResult(id, data.result, vars.autoLimit) - if (!disablePrettyExplain && showPrettyExplain && isExplainQuery(data.result)) { - snapV2.addExplainResult(id, data.result) + if (!disablePrettyExplain && isExplainQuery(data.result)) { + sessionSnap.addExplainResult(id, data.result) setActiveUtilityTab('explain') } else if (activeUtilityTab === 'explain') { // If on Explain tab but ran a non-EXPLAIN query, switch to Results tab @@ -275,7 +284,7 @@ export const SQLEditor = () => { } } - snapV2.addResultError(id, error, vars.autoLimit) + sessionSnap.addResultError(id, error, vars.autoLimit) } refocusEditorAfterRunIfNeeded() @@ -285,13 +294,13 @@ export const SQLEditor = () => { const { mutate: executeExplain, isPending: isExplainExecuting } = useExecuteSqlMutation({ onSuccess(data) { if (id) { - snapV2.addExplainResult(id, data.result) + sessionSnap.addExplainResult(id, data.result) setActiveUtilityTab('explain') } }, onError(error) { if (id) { - snapV2.addExplainResultError(id, error) + sessionSnap.addExplainResultError(id, error) setActiveUtilityTab('explain') } }, @@ -486,7 +495,7 @@ export const SQLEditor = () => { // Check for multiple statements - EXPLAIN only works on a single statement const statements = splitSqlStatements(sql) if (statements.length > 1) { - snapV2.addExplainResultError(id, { + sessionSnap.addExplainResultError(id, { message: 'EXPLAIN only works on a single SQL statement. Please select just one query to analyze.', }) @@ -537,7 +546,7 @@ export const SQLEditor = () => { databaseSelectorState.selectedDatabaseId, databases, lineHighlights, - snapV2, + sessionSnap, ]) useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, executeExplainQuery, { @@ -570,6 +579,8 @@ export const SQLEditor = () => { ) const onMount = (editor: IStandaloneCodeEditor) => { + setEditorMountCount((count) => count + 1) + const tabId = createTabId('sql', { id }) const tabData = tabs.tabsMap[tabId] @@ -584,7 +595,7 @@ export const SQLEditor = () => { const buildDebugPrompt = useCallback(() => { const snippet = snapV2.snippets[id] - const result = snapV2.results[id]?.[0] + const result = sessionSnap.results[id]?.[0] const sql = (snippet?.snippet.content?.unchecked_sql ?? '') .replace(sqlAiDisclaimerComment, '') .trim() @@ -592,12 +603,12 @@ export const SQLEditor = () => { const prompt = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}` return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${sql}\n\`\`\`` - }, [id, snapV2.results, snapV2.snippets]) + }, [id, sessionSnap.results, snapV2.snippets]) const onDebug = useCallback(async () => { try { const snippet = snapV2.snippets[id] - const result = snapV2.results[id]?.[0] + const result = sessionSnap.results[id]?.[0] openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) aiSnap.newChat({ name: 'Debug SQL snippet', @@ -617,7 +628,7 @@ export const SQLEditor = () => { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, snapV2.results, snapV2.snippets]) + }, [id, sessionSnap.results, snapV2.snippets]) const acceptAiHandler = useCallback(async () => { try { @@ -825,30 +836,40 @@ export const SQLEditor = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccessReadReplicas, databases, ref]) - useEffect(() => { - if (snapV2.diffContent !== undefined) { - const { diffType, sql }: { diffType: DiffType; sql: string } = snapV2.diffContent - const editorModel = editorRef.current?.getModel() - if (!editorModel) return + const drainDiffRequest = useEffectEvent(() => { + const request = diffRequest.pending + if (request === undefined) return - const existingValue = editorRef.current?.getValue() ?? '' - if (existingValue.length === 0) { - // if the editor is empty, just copy over the code - editorRef.current?.executeEdits('apply-ai-message', [ - { - text: `${sql}`, - range: editorModel.getFullModelRange(), - }, - ]) - } else { - const currentSql = editorRef.current?.getValue() - const diff = { original: currentSql || '', modified: sql } - setSourceSqlDiff(diff) - setSelectedDiffType(diffType) - } + const editorModel = editorRef.current?.getModel() + // Editor isn't ready yet; leave the request pending. editorMountCount bumps + // on mount and re-runs this effect, so the request applies once mounted. + if (!editorModel) return + + const { diffType, sql } = request + const existingValue = editorRef.current?.getValue() ?? '' + if (existingValue.length === 0) { + // if the editor is empty, just copy over the code + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } else { + const currentSql = editorRef.current?.getValue() + const diff = { original: currentSql || '', modified: sql } + setSourceSqlDiff(diff) + setSelectedDiffType(diffType) } + + // One-shot: drain the request so it can't re-apply to a later editor or session. + diffRequest.consumeDiffRequest() + }) + useEffect(() => { + drainDiffRequest() + // until we can upgrade eslint to ignore useEffectEvent // eslint-disable-next-line react-hooks/exhaustive-deps - }, [snapV2.diffContent]) + }, [diffRequest.pending, editorMountCount]) // We want to check if the diff editor is mounted and if it is, we want to show the widget // We also want to cleanup the widget when the diff editor is closed @@ -1072,13 +1093,16 @@ export const SQLEditor = () => { <DropdownMenuTrigger asChild> <Button variant="default" iconRight={<ChevronUp size={14} />}> Limit results to:{' '} - {ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === snapV2.limit)?.label} + { + ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit) + ?.label + } </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-40" align="end"> <DropdownMenuRadioGroup - value={snapV2.limit.toString()} - onValueChange={(val) => snapV2.setLimit(Number(val))} + value={sessionSnap.limit.toString()} + onValueChange={(val) => sessionSnap.setLimit(Number(val))} > {ROWS_PER_PAGE_OPTIONS.map((option) => ( <DropdownMenuRadioItem key={option.label} value={option.value.toString()}> diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 0e497cca467..9e01e8c24a2 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -9,11 +9,11 @@ import { updateWithoutWhereRegex, } from './SQLEditor.constants' import { ContentDiff } from './SQLEditor.types' +import type { SnippetWithContent } from '@/data/content/sql-folders-query' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' import { generateUuid } from '@/lib/api/snippets.browser' import { removeCommentsFromSql } from '@/lib/helpers' import { sqlEventParser } from '@/lib/sql-event-parser' -import type { SnippetWithContent } from '@/state/sql-editor-v2' export type CreateTableWithoutRLS = { schema?: string @@ -83,7 +83,7 @@ export const createSqlSnippetSkeletonV2 = ({ content_id: id ?? '', unchecked_sql: untrustedSql(sql ?? ''), } as any, - isNotSavedInDatabaseYet: true, + status: 'new', } } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLExamples.tsx b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLExamples.tsx index c9ec290ad1b..d95a2b75539 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLExamples.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLExamples.tsx @@ -12,7 +12,7 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' import { useTrack } from '@/lib/telemetry/track' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' const SQLExamples = () => { const router = useRouter() diff --git a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx index 07f3d132460..0b855813393 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx @@ -12,7 +12,7 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' import { useTrack } from '@/lib/telemetry/track' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' const SQLTemplates = () => { const router = useRouter() diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx index a54a698c6e2..74f7d6e7437 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx @@ -20,11 +20,12 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import BarChart from '@/components/ui/Charts/BarChart' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' +import { getCumulativeResults } from '@/components/ui/QueryBlock/QueryBlock.utils' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' type Results = { rows: readonly any[] } @@ -40,21 +41,6 @@ export type ChartConfig = { logScale?: boolean } -const getCumulativeResults = (results: Results, config: ChartConfig) => { - if (!results?.rows?.length) { - return [] - } - - const cumulativeResults = results.rows.reduce((acc, row) => { - const prev = acc[acc.length - 1] || {} - const next = { - ...row, - [config.yKey]: (prev[config.yKey] || 0) + row[config.yKey], - } - return [...acc, next] - }, []) - return cumulativeResults -} const VALID_RESULT_KEY_TYPES = ['number', 'string', 'date'] type ChartConfigProps = { diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ReadOnlyBadge.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ReadOnlyBadge.tsx index 253675db650..b4d56ef40c6 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ReadOnlyBadge.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ReadOnlyBadge.tsx @@ -1,8 +1,8 @@ import { Badge } from 'ui' import { useProfile } from '@/lib/profile' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' import { isSnippetOwner } from '@/state/sql-editor/sql-editor-rules' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export type ReadOnlyBadgeProps = { id: string } const ReadOnlyBadge = ({ id }: ReadOnlyBadgeProps) => { diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx index 9e42185427c..49f6883ea8e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx @@ -142,5 +142,3 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { </> ) } - -export default Results diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx index 12c34a4214d..cf10b862ce0 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx @@ -5,8 +5,10 @@ import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import ReadOnlyBadge from './ReadOnlyBadge' import { useProfile } from '@/lib/profile' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { isSaveFailed, isSaving } from '@/state/sql-editor/sql-editor-lifecycle' import { isSnippetOwner } from '@/state/sql-editor/sql-editor-rules' +import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export type SavingIndicatorProps = { id: string } @@ -14,17 +16,20 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => { const { profile } = useProfile() const snapV2 = useSqlEditorV2StateSnapshot() - const savingState = snapV2.savingStates[id] - const previousState = usePrevious(savingState) + const snippet = snapV2.snippets[id] + const status = snippet?.snippet.status + const saving = isSaving(status) + const saveFailed = isSaveFailed(status) + const previousSaving = usePrevious(saving) const [showSavedText, setShowSavedText] = useState(false) - const snippet = snapV2.snippets[id] const snippetIsOwned = !!snippet && isSnippetOwner(snippet.snippet, profile?.id) + const { requestSave } = useSqlEditorSaveCoordinator() useEffect(() => { let cancel = false - if (previousState === 'UPDATING' && savingState === 'IDLE') { + if (previousSaving && status === 'saved') { setShowSavedText(true) setTimeout(() => { if (!cancel) setShowSavedText(false) @@ -34,14 +39,14 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => { return () => { cancel = true } - }, [savingState]) + }, [status, previousSaving]) - const retry = () => snapV2.addNeedsSaving(id) + const retry = () => requestSave(id) return ( <> <div className="mx-2 flex items-center gap-2"> - {snippetIsOwned && savingState === 'UPDATING_FAILED' && ( + {snippetIsOwned && saveFailed && ( <Button variant="text" size="tiny" @@ -58,14 +63,14 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => { </TooltipTrigger> <TooltipContent side="bottom">All changes saved</TooltipContent> </Tooltip> - ) : savingState === 'UPDATING' ? ( + ) : saving ? ( <Tooltip> <TooltipTrigger> <Loader2 className="animate-spin" size={14} strokeWidth={2} /> </TooltipTrigger> <TooltipContent>Saving changes...</TooltipContent> </Tooltip> - ) : savingState === 'UPDATING_FAILED' ? ( + ) : saveFailed ? ( snippetIsOwned ? ( <Tooltip> <TooltipTrigger> diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 0f9fceee440..7164ea180ec 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -24,7 +24,8 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export type UtilityActionsProps = { id: string @@ -45,6 +46,7 @@ export const UtilityActions = ({ }: UtilityActionsProps) => { const { ref } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() + const sessionSnap = useSqlEditorSessionSnapshot() const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true) const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( @@ -75,7 +77,7 @@ export const UtilityActions = ({ const removeFavorite = () => snapV2.removeFavorite(id) const onSelectDatabase = (databaseId: string) => { - snapV2.resetResults(id) + sessionSnap.resetResults(id) setLastSelectedDb(databaseId) } @@ -133,13 +135,19 @@ export const UtilityActions = ({ <div className={cn('items-center gap-x-2', isAiOpen ? 'hidden 2xl:flex' : 'flex')}> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="text" - className="px-1" - icon={<Keyboard className="text-foreground-light" />} - /> - </DropdownMenuTrigger> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + variant="text" + className="px-1" + icon={<Keyboard className="text-foreground-light" />} + aria-label="Enable Intellisense" + /> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">Enable Intellisense</TooltipContent> + </Tooltip> <DropdownMenuContent className="w-48"> <DropdownMenuItem className="justify-between" onClick={toggleIntellisense}> Intellisense enabled @@ -158,6 +166,7 @@ export const UtilityActions = ({ onClick={removeFavorite} className="px-1" icon={<Heart className="fill-brand stroke-none" />} + aria-label="Remove from favorites" /> ) : ( <Button @@ -166,6 +175,7 @@ export const UtilityActions = ({ onClick={addFavorite} className="px-1" icon={<Heart className="fill-none stroke-foreground-light" />} + aria-label="Add to favorites" /> )} </TooltipTrigger> @@ -182,6 +192,7 @@ export const UtilityActions = ({ onClick={prettifyQuery} className="px-1" icon={<AlignLeft strokeWidth={2} className="text-foreground-light" />} + aria-label="Prettify SQL" /> </TooltipTrigger> <TooltipContent side="bottom" className="p-1 pl-2.5"> diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index d8ab07d67c4..60697aa58b1 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -10,7 +10,8 @@ import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' import { Snippet } from '@/data/content/sql-folders-query' import { useTrack } from '@/lib/telemetry/track' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export type UtilityPanelProps = { id: string @@ -57,9 +58,10 @@ export const UtilityPanel = ({ const { ref } = useParams() const track = useTrack() const snapV2 = useSqlEditorV2StateSnapshot() + const sessionSnap = useSqlEditorSessionSnapshot() const snippet = snapV2.snippets[id]?.snippet - const result = snapV2.results[id]?.[0] + const result = sessionSnap.results[id]?.[0] const handleTabChange = (tab: string) => { // When switching to the explain tab, trigger the explain query @@ -151,7 +153,7 @@ export const UtilityPanel = ({ <DownloadResultsButton variant="text" results={result.rows as any[]} - fileName={`Supabase Snippet ${snippet.name}`} + fileName={`Supabase Snippet ${snippet?.name ?? 'Results'}`} onDownloadAsCSV={() => track('sql_editor_result_download_csv_clicked')} onCopyAsMarkdown={() => track('sql_editor_result_copy_markdown_clicked')} onCopyAsJSON={() => track('sql_editor_result_copy_json_clicked')} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx index c6142c70c09..f7d2e1c2267 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabExplain.tsx @@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react' import { useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import Results from './Results' +import { Results } from './Results' import { ExplainVisualizer } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer' import { ExplainHeader } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.Header' import { @@ -10,7 +10,7 @@ import { isTextFormatExplain, } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' import CopyButton from '@/components/ui/CopyButton' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' export type UtilityTabExplainProps = { id: string @@ -18,8 +18,8 @@ export type UtilityTabExplainProps = { } export function UtilityTabExplain({ id, isExecuting }: UtilityTabExplainProps) { - const snapV2 = useSqlEditorV2StateSnapshot() - const explainResult = snapV2.explainResults[id] + const sessionSnap = useSqlEditorSessionSnapshot() + const explainResult = sessionSnap.explainResults[id] const [mode, setMode] = useState<'visual' | 'raw'>('visual') if (isExecuting) { diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx index dbc3f603bf6..37ac910923d 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -4,7 +4,7 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { forwardRef } from 'react' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import Results from './Results' +import { Results } from './Results' import { getSqlErrorLines } from './UtilityTabResults.utils' import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' @@ -15,7 +15,7 @@ import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-q import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' export type UtilityTabResultsProps = { id: string @@ -31,10 +31,10 @@ export const UtilityTabResults = forwardRef<HTMLDivElement, UtilityTabResultsPro const { ref } = useParams() const state = useDatabaseSelectorStateSnapshot() const { data: organization } = useSelectedOrganizationQuery() - const snapV2 = useSqlEditorV2StateSnapshot() + const sessionSnap = useSqlEditorSessionSnapshot() const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false)) - const result = snapV2.results[id]?.[0] + const result = sessionSnap.results[id]?.[0] const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) // Customers on HIPAA plans should not have access to Supabase AI @@ -139,7 +139,7 @@ export const UtilityTabResults = forwardRef<HTMLDivElement, UtilityTabResultsPro variant="default" onClick={() => { state.setSelectedDatabaseId(ref) - snapV2.resetResults(id) + sessionSnap.resetResults(id) }} > Switch to primary database diff --git a/apps/studio/components/interfaces/SQLEditor/hooks.ts b/apps/studio/components/interfaces/SQLEditor/hooks.ts index 047056aca21..7c7b2e391ce 100644 --- a/apps/studio/components/interfaces/SQLEditor/hooks.ts +++ b/apps/studio/components/interfaces/SQLEditor/hooks.ts @@ -14,7 +14,7 @@ import { import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export const useNewQuery = () => { const router = useRouter() @@ -149,5 +149,3 @@ export function useSqlEditorPrompt() { resetPrompt, } } - -export default useNewQuery diff --git a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts index e26c2f4fd44..f3a0ceabbc9 100644 --- a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts +++ b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts @@ -12,7 +12,7 @@ import { useTableColumnsQuery } from '@/data/database/table-columns-query' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export const useAddDefinitions = (id: string, monaco: Monaco | null) => { const { data: project } = useSelectedProjectQuery() diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.ts b/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.ts new file mode 100644 index 00000000000..76c5a139296 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.ts @@ -0,0 +1,81 @@ +import { useParams } from 'common' +import { useRouter } from 'next/router' +import { useEffect, useEffectEvent } from 'react' + +import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useProfile } from '@/lib/profile' +import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle' +import { canEditSnippet } from '@/state/sql-editor/sql-editor-rules' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' +import { useTabsStateSnapshot } from '@/state/tabs' + +/** + * Owns the editing lifecycle of a single SQL snippet: creating the snippet in + * the store on first edit (and routing to its URL), writing changes back to the + * store via `setSql`, and seeding the editor from a `?content=` deep link. + * + * Edits are written to the store synchronously on every change; debouncing the + * actual persistence is the save mechanism's job (see `createSaveMechanism`), so + * the store — and therefore the snippet's dirty status — always reflects the + * latest edit immediately. + * + * Extracted from `MonacoEditor` so that component stays a thin editor shell. + */ +export function useSnippetEditor({ id, snippetName }: { id: string; snippetName: string }) { + const router = useRouter() + const { profile } = useProfile() + const { ref, content } = useParams() + const { data: project } = useSelectedProjectQuery() + + const snapV2 = useSqlEditorV2StateSnapshot() + const tabsSnap = useTabsStateSnapshot() + + const snippet = snapV2.snippets[id] + const disableEdit = !!snippet && !canEditSnippet(snippet.snippet, profile?.id) + + function handleEditorChange(value: string | undefined) { + tabsSnap.makeActiveTabPermanent() + if (!id || !value) return + + if (!snippet && ref && profile !== undefined && project !== undefined) { + const newSnippet = createSqlSnippetSkeletonV2({ + idOverride: id, + name: snippetName, + sql: value, + owner_id: profile?.id, + project_id: project?.id, + }) + snapV2.addSnippet({ projectRef: ref, snippet: newSnippet }) + // When the editor was seeded from a `content` deep-link, replace rather + // than push. The caller navigated to `/sql/new?content=...` (a long, + // one-shot URL); replacing collapses it out of history so Back returns to + // the originating page instead of a wasted step that re-seeds the snippet. + if (router.query.content !== undefined) { + router.replace(`/project/${ref}/sql/${newSnippet.id}`, undefined, { shallow: true }) + } else { + router.push(`/project/${ref}/sql/${newSnippet.id}`, undefined, { shallow: true }) + } + } + + // A snippet that has never been persisted needs the snippet/folder lists + // invalidated so it shows up in the sidebar. If `snippet` was undefined at + // render we just created one above (status 'new'), which always qualifies. + const shouldInvalidate = snippet ? wasNeverPersisted(snippet.snippet.status) : true + snapV2.setSql({ id, sql: value, shouldInvalidate }) + } + + // if an SQL query is passed by the content parameter, set the editor value to its content. This + // is usually used for sending the user to SQL editor from other pages with SQL. + const seedFromContentParam = useEffectEvent(() => { + if (content && content.length > 0) handleEditorChange(content) + }) + useEffect(() => { + seedFromContentParam() + // The useEffectEvent return is stable and must not be a dependency; this + // disable can go once our eslint version understands useEffectEvent. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { snippet, disableEdit, handleEditorChange } +} diff --git a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx index 887c5fd1bf5..59e9016b8ba 100644 --- a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx +++ b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx @@ -3,6 +3,7 @@ import { AlertCircle } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' import { useEffect, useEffectEvent } from 'react' import { Alert, AlertTitle } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { PageSection, PageSectionAside, @@ -11,8 +12,7 @@ import { PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import { Input } from 'ui-patterns/DataInputs/Input' +} from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getApiEndpoint } from '@/components/interfaces/Integrations/DataApi/DataApi.utils' diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index 69fdc9f6881..a7e1f3e0e1a 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -23,7 +23,6 @@ import { Switch, useWatch, } from 'ui' -import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -33,6 +32,8 @@ import { MultiSelectorList, MultiSelectorTrigger, } from 'ui-patterns/multi-select' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { z } from 'zod' import { ExposedSchemaSelector, internalSchemasCannotExpose } from './ExposedSchemaSelector' @@ -48,7 +49,7 @@ import { privilegeKeys } from '@/data/privileges/keys' import { useUpdateDefaultPrivilegesMutation } from '@/data/privileges/update-default-privileges-mutation' import { useUpdateExposedEntitiesMutation } from '@/data/privileges/update-exposed-entities-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' import { noop } from '@/lib/void' @@ -136,7 +137,12 @@ export const PostgrestConfig = () => { const { can: canUpdatePostgrestConfigPermission, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest') + // PostgREST config (exposed schemas, extra search path, max rows, pool size) is persisted via + // the platform API, which isn't available self-hosted (the values come from env vars there). const canUpdatePostgrestConfig = IS_PLATFORM && canUpdatePostgrestConfigPermission + // Entity exposure (tables, functions, default privileges) is applied with SQL GRANT/REVOKE + // directly against the database, so it works both on the platform and self-hosted. + const canUpdateExposedEntities = canUpdatePostgrestConfigPermission const defaultValues = useMemo(() => { return { @@ -191,16 +197,20 @@ export const PostgrestConfig = () => { }) } - await updatePostgrestConfig( - { - projectRef, - dbSchema, - maxRows: values.maxRows, - dbExtraSearchPath: values.dbExtraSearchPath.join(','), - dbPool: values.dbPool ? values.dbPool : null, - }, - { onError: noop } - ) + // The PostgREST config endpoint isn't available self-hosted, so only persist these fields on + // the platform. Entity exposure above is applied via SQL and works in both environments. + if (canUpdatePostgrestConfig) { + await updatePostgrestConfig( + { + projectRef, + dbSchema, + maxRows: values.maxRows, + dbExtraSearchPath: values.dbExtraSearchPath.join(','), + dbPool: values.dbPool ? values.dbPool : null, + }, + { onError: noop } + ) + } await Promise.all([ queryClient.invalidateQueries({ @@ -428,7 +438,7 @@ export const PostgrestConfig = () => { <div> <Switch size="large" - disabled={!canUpdatePostgrestConfig} + disabled={!canUpdateExposedEntities} checked={field.value} onCheckedChange={field.onChange} /> @@ -568,22 +578,20 @@ export const PostgrestConfig = () => { )} </form> </Form> - {IS_PLATFORM && ( - <CardFooter className="border-t"> - <FormActions - form={formId} - isSubmitting={isUpdating} - hasChanges={form.formState.isDirty} - handleReset={resetForm} - disabled={!canUpdatePostgrestConfig} - helper={ - isPermissionsLoaded && !canUpdatePostgrestConfigPermission - ? "You need additional permissions to update your project's API settings" - : undefined - } - /> - </CardFooter> - )} + <CardFooter className="border-t"> + <FormActions + form={formId} + isSubmitting={isUpdating} + hasChanges={form.formState.isDirty} + handleReset={resetForm} + disabled={!canUpdateExposedEntities} + helper={ + isPermissionsLoaded && !canUpdateExposedEntities + ? "You need additional permissions to update your project's API settings" + : undefined + } + /> + </CardFooter> </Card> {IS_PLATFORM && ( <Card className="mb-4"> diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index db2b8e2ef4d..22b304adde1 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -13,7 +13,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection } from 'ui-patterns/PageSection' @@ -32,7 +32,7 @@ import { } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { ProjectUpdateDisabledTooltip } from '@/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' import { SupportLink } from '@/components/interfaces/Support/SupportLink' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { ResourceItem } from '@/components/ui/Resource/ResourceItem' import { ResourceList } from '@/components/ui/Resource/ResourceList' diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 0bf56a72a55..b27ec4e139d 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -3,7 +3,7 @@ import { useParams } from 'common' import { useEffect, useState } from 'react' import { toast } from 'sonner' import { cn, RadioGroup, RadioGroupLargeItem, SidePanel } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { DocsButton } from '@/components/ui/DocsButton' diff --git a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx index 83bcb9f257d..0a2b5927bf6 100644 --- a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx +++ b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx @@ -4,6 +4,7 @@ import { Globe } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { Badge, Card, CardContent, Skeleton } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PageSection, PageSectionContent, @@ -11,22 +12,28 @@ import { PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +} from 'ui-patterns/PageSection' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' +import { HighAvailabilityDisabledSectionNotice } from '@/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice' import { useBannedIPsDeleteMutation } from '@/data/banned-ips/banned-ips-delete-mutations' import { useBannedIPsQuery } from '@/data/banned-ips/banned-ips-query' import { useUserIPAddressQuery } from '@/data/misc/user-ip-address-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' +const HA_DISABLED_TITLE = 'Network bans unavailable on High Availability projects' +const HA_DISABLED_DESCRIPTION = + "We're working to bring network bans to High Availability projects. Contact support if this is blocking your work." + export const BannedIPs = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const [selectedIPToUnban, setSelectedIPToUnban] = useState<string | null>(null) // Track the selected IP for unban @@ -52,6 +59,11 @@ export const BannedIPs = () => { }, }) + const isSectionDisabled = isHighAvailability || !canUnbanNetworks + const sectionDisabledReason = isHighAvailability + ? HA_DISABLED_TITLE + : 'You need additional permissions to unban networks' + const { mutate: unbanIPs, isPending: isUnbanning } = useBannedIPsDeleteMutation({ onSuccess: () => { toast.success('IP address successfully unbanned') @@ -90,49 +102,56 @@ export const BannedIPs = () => { <DocsButton href={`${DOCS_URL}/reference/cli/supabase-network-bans`} /> </PageSectionMeta> <PageSectionContent> - {ipListLoading ? ( - <Card> - <CardContent className="space-y-4"> - <Skeleton className="h-4 w-full" /> - <Skeleton className="h-4 w-full" /> - </CardContent> - </Card> - ) : ipListError ? ( - <AlertError error={ipListError} subject="Failed to retrieve banned IP addresses" /> - ) : ipList.banned_ipv4_addresses.length > 0 ? ( - <Card> - {ipList.banned_ipv4_addresses.map((ip) => ( - <CardContent key={ip} className="flex items-center justify-between"> - <div className="flex items-center space-x-5"> - <Globe size={16} className="text-foreground-lighter" /> - <p className="text-sm font-mono">{ip}</p> - {ip === userIPAddress && <Badge>Your IP address</Badge>} - </div> - <ButtonTooltip - variant="default" - disabled={!canUnbanNetworks} - onClick={() => openConfirmationModal(ip)} - tooltip={{ - content: { - side: 'bottom', - text: !canUnbanNetworks - ? 'You need additional permissions to unban networks' - : undefined, - }, - }} - > - Unban IP - </ButtonTooltip> - </CardContent> - ))} - </Card> - ) : ( - <Card> - <CardContent className="text-foreground text-sm"> - There are no banned IP addresses for your project - </CardContent> - </Card> + {isHighAvailability && ( + <div className="mb-4"> + <HighAvailabilityDisabledSectionNotice + title={HA_DISABLED_TITLE} + description={HA_DISABLED_DESCRIPTION} + /> + </div> )} + {!isHighAvailability && + (ipListLoading ? ( + <Card> + <CardContent className="space-y-4"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + </CardContent> + </Card> + ) : ipListError ? ( + <AlertError error={ipListError} subject="Failed to retrieve banned IP addresses" /> + ) : ipList.banned_ipv4_addresses.length > 0 ? ( + <Card> + {ipList.banned_ipv4_addresses.map((ip) => ( + <CardContent key={ip} className="flex items-center justify-between"> + <div className="flex items-center space-x-5"> + <Globe size={16} className="text-foreground-lighter" /> + <p className="text-sm font-mono">{ip}</p> + {ip === userIPAddress && <Badge>Your IP address</Badge>} + </div> + <ButtonTooltip + variant="default" + disabled={isSectionDisabled} + onClick={() => openConfirmationModal(ip)} + tooltip={{ + content: { + side: 'bottom', + text: isSectionDisabled ? sectionDisabledReason : undefined, + }, + }} + > + Unban IP + </ButtonTooltip> + </CardContent> + ))} + </Card> + ) : ( + <Card> + <CardContent className="text-foreground text-sm"> + There are no banned IP addresses for your project + </CardContent> + </Card> + ))} </PageSectionContent> </PageSection> diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionLogging.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionLogging.tsx index cf30774357b..5290347e0ad 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionLogging.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionLogging.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, PageSectionAside, @@ -13,11 +14,10 @@ import { PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +} from 'ui-patterns/PageSection' import z from 'zod' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' import { usePostgresConfigurationUpdateMutation } from '@/data/config/postgres-config-mutation' diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 36104ba7e43..1d89f386db3 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -21,7 +21,7 @@ import { InputGroupText, Separator, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -35,7 +35,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import z from 'zod' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { FormActions } from '@/components/ui/Forms/FormActions' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 79de0684859..7b0d1499db4 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -37,5 +37,3 @@ export const ResetDbPassword = ({ disabled = false }) => { </PageSection> ) } - -export default ResetDbPassword diff --git a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx index ee568cd6024..45ab64cc64b 100644 --- a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx @@ -11,7 +11,7 @@ import { PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' +} from 'ui-patterns/PageSection' import { Markdown } from '@/components/interfaces/Markdown' import DiskSizeConfigurationModal from '@/components/interfaces/Settings/Database/DiskSizeConfigurationModal' diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx index 9aaafb4ebb7..2e247c35eee 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx @@ -23,15 +23,15 @@ import { TooltipContent, TooltipTrigger, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' import { PageSection, PageSectionContent, PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import { Admonition } from 'ui-patterns/admonition' -import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' +} from 'ui-patterns/PageSection' import type { JitUserRule, SheetMode } from './JitDbAccess.types' import { @@ -43,7 +43,7 @@ import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog' import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet' import { JitDbAccessRulesTable } from './JitDbAccessRulesTable' import { SupportLink } from '@/components/interfaces/Support/SupportLink' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx index bc4fbcec402..619f7721e2e 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRoleGrantFields.tsx @@ -9,10 +9,10 @@ import { SelectValue, WarningIcon, } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import type { JitRoleGrantDraft, JitRoleOption, JitUserRuleDraft } from './JitDbAccess.types' import { createEmptyIpRange, getRelativeDatetimeByMode } from './JitDbAccess.utils' diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx index 480a970261f..3b5af9abac3 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx @@ -22,7 +22,7 @@ import { SheetHeader, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index 3e5b156d3bc..f4275aa8748 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -13,9 +13,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, } from 'ui' import { PageSection, @@ -24,7 +21,7 @@ import { PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' +} from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import AddRestrictionModal from './AddRestrictionModal' @@ -33,32 +30,43 @@ import DisallowAllModal from './DisallowAllModal' import RemoveRestrictionModal from './RemoveRestrictionModal' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' +import { HighAvailabilityDisabledSectionNotice } from '@/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice' import { useNetworkRestrictionsQuery } from '@/data/network-restrictions/network-restrictions-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' +const HA_DISABLED_TITLE = 'Network restrictions unavailable on High Availability projects' +const HA_DISABLED_DESCRIPTION = + "We're working to bring network restrictions to High Availability projects. Contact support if this is blocking your work." + interface AccessButtonProps { disabled: boolean + disabledTooltip?: string onClick: (value: boolean) => void } -const AllowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => ( - <Tooltip> - <TooltipTrigger asChild> - <Button variant="default" disabled={disabled} onClick={() => onClick(true)}> - Allow all access - </Button> - </TooltipTrigger> - {disabled && ( - <TooltipContent side="bottom"> - You need additional permissions to update network restrictions - </TooltipContent> - )} - </Tooltip> +const getDisabledTooltip = (disabled: boolean, disabledTooltip?: string) => + disabled ? disabledTooltip : undefined + +const AllowAllAccessButton = ({ disabled, disabledTooltip, onClick }: AccessButtonProps) => ( + <ButtonTooltip + variant="default" + disabled={disabled} + onClick={() => onClick(true)} + tooltip={{ + content: { + side: 'bottom', + text: getDisabledTooltip(disabled, disabledTooltip), + }, + }} + > + Allow all access + </ButtonTooltip> ) -const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => ( +const DisallowAllAccessButton = ({ disabled, disabledTooltip, onClick }: AccessButtonProps) => ( <ButtonTooltip disabled={disabled} variant="default" @@ -66,9 +74,7 @@ const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => ( tooltip={{ content: { side: 'bottom', - text: disabled - ? 'You need additional permissions to update network restrictions' - : undefined, + text: getDisabledTooltip(disabled, disabledTooltip), }, }} > @@ -79,6 +85,7 @@ const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => ( export const NetworkRestrictions = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const [isAddingAddress, setIsAddingAddress] = useState<undefined | 'IPv4' | 'IPv6'>() const [isAllowingAll, setIsAllowingAll] = useState(false) const [isDisallowingAll, setIsDisallowingAll] = useState(false) @@ -98,6 +105,11 @@ export const NetworkRestrictions = () => { } ) + const isSectionDisabled = isHighAvailability || !canUpdateNetworkRestrictions + const sectionDisabledReason = isHighAvailability + ? HA_DISABLED_TITLE + : 'You need additional permissions to update network restrictions' + const hasAccessToRestrictions = data?.entitlement === 'allowed' const ipv4Restrictions = data?.config?.dbAllowedCidrs ?? [] // @ts-ignore [Joshen] API typing issue @@ -121,14 +133,14 @@ export const NetworkRestrictions = () => { </PageSectionSummary> <PageSectionAside className="flex items-center gap-x-2"> <DocsButton href={`${DOCS_URL}/guides/platform/network-restrictions`} /> - {!canUpdateNetworkRestrictions ? ( + {!canUpdateNetworkRestrictions || isHighAvailability ? ( <ButtonTooltip disabled variant="primary" tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to update network restrictions', + text: sectionDisabledReason, }, }} > @@ -139,7 +151,7 @@ export const NetworkRestrictions = () => { <DropdownMenuTrigger asChild> <Button variant="primary" - disabled={!canUpdateNetworkRestrictions} + disabled={isSectionDisabled} iconRight={<ChevronDown size={14} />} > Add restriction @@ -166,140 +178,171 @@ export const NetworkRestrictions = () => { </PageSectionAside> </PageSectionMeta> <PageSectionContent> - {isLoading ? ( - <Card> - <CardContent> - <div className="space-y-2"> - <ShimmeringLoader /> - <ShimmeringLoader className="w-[70%]" /> - <ShimmeringLoader className="w-[50%]" /> - </div> - </CardContent> - </Card> - ) : hasApplyError ? ( - <Card> - <CardContent> - <div className="flex items-center justify-between"> + {isHighAvailability && ( + <div className="mb-4"> + <HighAvailabilityDisabledSectionNotice + title={HA_DISABLED_TITLE} + description={HA_DISABLED_DESCRIPTION} + /> + </div> + )} + {!isHighAvailability && + (isLoading ? ( + <Card> + <CardContent> <div className="space-y-2"> - <div className="flex items-center space-x-2"> - <AlertCircle size={20} strokeWidth={1.5} className="text-foreground-light" /> - <p className="text-sm"> - Your network restrictions were not applied correctly - </p> - </div> - <p className="text-sm text-foreground-light"> - Please try to add your network restrictions again - </p> - </div> - <div className="flex items-center space-x-2"> - <AllowAllAccessButton - disabled={!canUpdateNetworkRestrictions} - onClick={setIsAllowingAll} - /> - <DisallowAllAccessButton - disabled={!canUpdateNetworkRestrictions} - onClick={setIsDisallowingAll} - /> - </div> - </div> - </CardContent> - </Card> - ) : ( - <Card> - {isUninitialized || isAllowedAll ? ( - <CardContent className="flex items-center justify-between"> - <div className="flex items-start space-x-4"> - <div className="space-y-0.5"> - <p className="text-foreground text-sm"> - Your database can be accessed by all IP addresses - </p> - <p className="text-foreground-light text-sm"> - You may start limiting access to your database by adding a network - restriction. - </p> - </div> - </div> - <div> - <DisallowAllAccessButton - disabled={!canUpdateNetworkRestrictions} - onClick={setIsDisallowingAll} - /> + <ShimmeringLoader /> + <ShimmeringLoader className="w-[70%]" /> + <ShimmeringLoader className="w-[50%]" /> </div> </CardContent> - ) : isDisallowedAll ? ( - <CardContent className="flex items-center justify-between"> - <div className="flex items-start space-x-4"> - <Lock size={20} className="text-foreground-light" strokeWidth={1.5} /> - <div className="space-y-1"> - <p className="text-foreground-light text-sm"> - Your database <span className="text-amber-900 opacity-80">cannot</span> be - accessed externally - </p> - <p className="text-foreground-light text-sm"> - All external IP addresses have been disallowed from accessing your project's - database. - </p> - <p className="text-foreground-light text-sm"> - Note: Restrictions only apply to your database, and not to Supabase services + </Card> + ) : hasApplyError ? ( + <Card> + <CardContent> + <div className="flex items-center justify-between"> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <AlertCircle + size={20} + strokeWidth={1.5} + className="text-foreground-light" + /> + <p className="text-sm"> + Your network restrictions were not applied correctly + </p> + </div> + <p className="text-sm text-foreground-light"> + Please try to add your network restrictions again </p> </div> - </div> - <div> - <AllowAllAccessButton - disabled={!canUpdateNetworkRestrictions} - onClick={setIsAllowingAll} - /> - </div> - </CardContent> - ) : ( - <> - <CardHeader className="md:flex-row md:items-center justify-between"> - <CardDescription className="text-foreground-light"> - <p>Only the following IP addresses have access to your database.</p> - <p> - You may remove all of them to allow all IP addresses to have access to your - database. - </p> - <p> - Note: Restrictions only apply to your database, and not to Supabase services - </p> - </CardDescription> <div className="flex items-center space-x-2"> <AllowAllAccessButton - disabled={!canUpdateNetworkRestrictions} + disabled={isSectionDisabled} + disabledTooltip={sectionDisabledReason} onClick={setIsAllowingAll} /> <DisallowAllAccessButton - disabled={!canUpdateNetworkRestrictions} + disabled={isSectionDisabled} + disabledTooltip={sectionDisabledReason} onClick={setIsDisallowingAll} /> </div> - </CardHeader> - <CardContent className="py-0"> - <div className="divide-y"> - {restrictedIps.map((ip) => { - return ( - <div key={ip} className="py-4 flex items-center justify-between"> - <div className="flex items-center space-x-5"> - <Globe size={16} className="text-foreground-lighter" /> - <Badge>{ipv4Restrictions.includes(ip) ? 'IPv4' : 'IPv6'}</Badge> - <p className="text-sm font-mono">{ip}</p> - </div> - <Button - variant="default" - onClick={() => setSelectedRestrictionToRemove(ip)} - > - Remove - </Button> - </div> - ) - })} + </div> + </CardContent> + </Card> + ) : ( + <Card> + {isUninitialized || isAllowedAll ? ( + <CardContent className="flex items-center justify-between"> + <div className="flex items-start space-x-4"> + <div className="space-y-0.5"> + <p className="text-foreground text-sm"> + Your database can be accessed by all IP addresses + </p> + <p className="text-foreground-light text-sm"> + You may start limiting access to your database by adding a network + restriction. + </p> + </div> + </div> + <div> + <DisallowAllAccessButton + disabled={isSectionDisabled} + disabledTooltip={sectionDisabledReason} + onClick={setIsDisallowingAll} + /> </div> </CardContent> - </> - )} - </Card> - )} + ) : isDisallowedAll ? ( + <CardContent className="flex items-center justify-between"> + <div className="flex items-start space-x-4"> + <Lock size={20} className="text-foreground-light" strokeWidth={1.5} /> + <div className="space-y-1"> + <p className="text-foreground-light text-sm"> + Your database <span className="text-amber-900 opacity-80">cannot</span> be + accessed externally + </p> + <p className="text-foreground-light text-sm"> + All external IP addresses have been disallowed from accessing your + project's database. + </p> + <p className="text-foreground-light text-sm"> + Note: Restrictions only apply to your database, and not to Supabase + services + </p> + </div> + </div> + <div> + <AllowAllAccessButton + disabled={isSectionDisabled} + disabledTooltip={sectionDisabledReason} + onClick={setIsAllowingAll} + /> + </div> + </CardContent> + ) : ( + <> + <CardHeader className="md:flex-row md:items-center justify-between"> + <CardDescription className="text-foreground-light"> + <p>Only the following IP addresses have access to your database.</p> + <p> + You may remove all of them to allow all IP addresses to have access to + your database. + </p> + <p> + Note: Restrictions only apply to your database, and not to Supabase + services + </p> + </CardDescription> + <div className="flex items-center space-x-2"> + <AllowAllAccessButton + disabled={isSectionDisabled} + disabledTooltip={sectionDisabledReason} + onClick={setIsAllowingAll} + /> + <DisallowAllAccessButton + disabled={isSectionDisabled} + disabledTooltip={sectionDisabledReason} + onClick={setIsDisallowingAll} + /> + </div> + </CardHeader> + <CardContent className="py-0"> + <div className="divide-y"> + {restrictedIps.map((ip) => { + return ( + <div key={ip} className="py-4 flex items-center justify-between"> + <div className="flex items-center space-x-5"> + <Globe size={16} className="text-foreground-lighter" /> + <Badge>{ipv4Restrictions.includes(ip) ? 'IPv4' : 'IPv6'}</Badge> + <p className="text-sm font-mono">{ip}</p> + </div> + <ButtonTooltip + variant="default" + disabled={isSectionDisabled} + onClick={() => setSelectedRestrictionToRemove(ip)} + tooltip={{ + content: { + side: 'bottom', + text: getDisabledTooltip( + isSectionDisabled, + sectionDisabledReason + ), + }, + }} + > + Remove + </ButtonTooltip> + </div> + ) + })} + </div> + </CardContent> + </> + )} + </Card> + ))} </PageSectionContent> </PageSection> diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/RemoveRestrictionModal.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/RemoveRestrictionModal.tsx index 4dea1c1814b..5c1bfb0a53f 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/RemoveRestrictionModal.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/RemoveRestrictionModal.tsx @@ -10,7 +10,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useNetworkRestrictionsQuery } from '@/data/network-restrictions/network-restrictions-query' import { useNetworkRestrictionsApplyMutation } from '@/data/network-restrictions/network-retrictions-apply-mutation' diff --git a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx index 9648baed9be..d4ee5778de4 100644 --- a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx +++ b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx @@ -13,7 +13,7 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { InlineLink } from '@/components/ui/InlineLink' import { useComplianceConfigUpdateMutation } from '@/data/config/project-compliance-config-mutation' diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/DNSRecord.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/DNSRecord.tsx index 1dee9db314f..15eddc03696 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/DNSRecord.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/DNSRecord.tsx @@ -18,5 +18,3 @@ export const DNSRecord = ({ type, name, value }: DNSRecordProps) => { </div> ) } - -export default DNSRecord diff --git a/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx index d98545f1d66..c6b6c440440 100644 --- a/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx +++ b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx @@ -21,7 +21,7 @@ import { FormControl, FormField, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -30,7 +30,7 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' -import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { DatabaseSelector } from '@/components/ui/DatabaseSelector' diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index d660aaa9429..8525945abb4 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -4,7 +4,7 @@ import { IS_PLATFORM } from 'common' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Input } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { diff --git a/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx b/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx index 18831b0c7a8..9a0085353d7 100644 --- a/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx +++ b/apps/studio/components/interfaces/Settings/General/ProjectAccessSection.tsx @@ -21,7 +21,7 @@ import { import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { summarizeProjectAccess } from './General.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' diff --git a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx index 5ab2e71883c..e6e265d6eae 100644 --- a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx @@ -23,7 +23,7 @@ import { SelectValue, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx index fdd9b65f092..cde130f2aab 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx @@ -26,7 +26,7 @@ import { DateRangePicker } from '@/components/ui/DateRangePicker' import { DocsButton } from '@/components/ui/DocsButton' import Panel from '@/components/ui/Panel' import { DataPoint } from '@/data/analytics/constants' -import { mapMultiResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries' +import { mapResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries' import { InfraMonitoringAttribute, useInfraMonitoringAttributesQuery, @@ -166,7 +166,7 @@ export const InfrastructureActivity = () => { const transformedData = useMemo(() => { if (!infraMonitoringData) return undefined - return mapMultiResponseToAnalyticsData(infraMonitoringData, INFRA_ATTRIBUTES, dateFormat) + return mapResponseToAnalyticsData(infraMonitoringData, INFRA_ATTRIBUTES, dateFormat) }, [infraMonitoringData, dateFormat]) const cpuUsageData = transformedData?.max_cpu_usage diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 160dce03c17..f8f898ad978 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -36,7 +36,7 @@ import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceCo import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode' import MapView from './MapView' import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query' import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query' diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index 3ac1a9ae727..a36da60c59e 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -19,7 +19,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ERROR_STATES, @@ -34,7 +34,7 @@ import { } from './InstanceConfiguration.constants' import { formatSeconds } from './InstanceConfiguration.utils' import { metricColor } from './InstanceNode.utils' -import SparkBar from '@/components/ui/SparkBar' +import { SparkBar } from '@/components/ui/SparkBar' import { DatabaseInitEstimations, ReplicaInitializationStatus, diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index 61ee64a8a2f..5274d4ac83b 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -26,7 +26,7 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useProjectUpgradeEligibilityQuery } from '@/data/config/project-upgrade-eligibility-query' import { useProjectServiceVersionsQuery } from '@/data/projects/project-service-versions' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx index b570d00c3a1..62d1dea190e 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx @@ -17,7 +17,7 @@ import { SheetSection, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index 1f181eb7617..8450bdee8d9 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -18,7 +18,7 @@ import { GitHubIntegrationConnectionForm } from './GitHubIntegrationConnectionFo import { IntegrationConnectionItem } from '@/components/interfaces/Integrations/VercelGithub/IntegrationConnection' import { EmptyIntegrationConnection } from '@/components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' import { useGitHubConnectionDeleteMutation } from '@/data/integrations/github-connection-delete-mutation' import { diff --git a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx index 03d16b8ca75..5422d72ba50 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx @@ -1,5 +1,5 @@ import { Card } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { AWSPrivateLinkSection } from './AWSPrivateLink/AWSPrivateLinkSection' import { GitHubSection } from './GithubIntegration/GithubSection' diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx index d4807abd37c..f8b133fb6ec 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import Link from 'next/link' -import { useForm } from 'react-hook-form' +import { useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Alert, @@ -16,9 +17,13 @@ import { Input, Switch, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { FormActions } from '@/components/ui/Forms/FormActions' +import { InlineLink } from '@/components/ui/InlineLink' import type { EnvironmentTargets, Integration, @@ -26,6 +31,7 @@ import type { } from '@/data/integrations/integrations.types' import { useVercelConnectionUpdateMutation } from '@/data/integrations/vercel-connection-update-mutate' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' const VercelIntegrationConnectionForm = ({ @@ -40,6 +46,8 @@ const VercelIntegrationConnectionForm = ({ // NOTE(kamil): Ignore sync targets for Vercel Marketplace as it's not synchronized using integration, // but through a separate marketplace mechanism. It's not theoretically necessary, but we might have some stale data. const { data: org } = useSelectedOrganizationQuery() + const { data: project } = useSelectedProjectQuery() + const isBranchingEnabled = project?.is_branch_enabled === true const envSyncTargets = org?.managed_by === 'vercel-marketplace' ? [] : (connection.env_sync_targets ?? []) @@ -60,34 +68,58 @@ const VercelIntegrationConnectionForm = ({ }, }) + const [showConfirmation, setShowConfirmation] = useState(false) + const [pendingValues, setPendingValues] = useState<z.infer<typeof FormSchema> | null>(null) + const { mutate: updateVercelConnection, isPending } = useVercelConnectionUpdateMutation({ onSuccess: () => { form.reset(form.getValues()) + setShowConfirmation(false) + setPendingValues(null) toast.success(`Updated Vercel connection`) }, }) - function onSubmit(data: z.infer<typeof FormSchema>) { - const { - environmentVariablesProduction, - environmentVariablesPreview, - environmentVariablesDevelopment, - } = data + // Syncing the `preview` or `development` target sends this project's production credentials + // (anon and service role keys, database connection strings) to those Vercel environments. Watch + // the toggles so we can warn inline and gate the save behind a confirmation. + const [syncPreview, syncDevelopment] = useWatch({ + control: form.control, + name: ['environmentVariablesPreview', 'environmentVariablesDevelopment'], + }) + const isSyncingNonProdEnvs = syncPreview || syncDevelopment - const envSyncTargets: string[] = [] + const selectedNonProdTargets: string[] = [] + if (pendingValues?.environmentVariablesPreview) selectedNonProdTargets.push('Preview') + if (pendingValues?.environmentVariablesDevelopment) selectedNonProdTargets.push('Development') - if (environmentVariablesProduction) envSyncTargets.push('production') - if (environmentVariablesPreview) envSyncTargets.push('preview') - if (environmentVariablesDevelopment) envSyncTargets.push('development') + function performUpdate(data: z.infer<typeof FormSchema>) { + const targets: EnvironmentTargets[] = [] + + if (data.environmentVariablesProduction) targets.push('production') + if (data.environmentVariablesPreview) targets.push('preview') + if (data.environmentVariablesDevelopment) targets.push('development') updateVercelConnection({ id: connection.id, - envSyncTargets: envSyncTargets as EnvironmentTargets[], + envSyncTargets: targets, publicEnvVarPrefix: data.publicEnvVarPrefix?.trim(), organizationIntegrationId: integration.id, }) } + function onSubmit(data: z.infer<typeof FormSchema>) { + // Always confirm when Preview/Development sync is enabled to discourage leaking production + // credentials into preview deployments and steer users toward Branching instead. + if (data.environmentVariablesPreview || data.environmentVariablesDevelopment) { + setPendingValues(data) + setShowConfirmation(true) + return + } + + performUpdate(data) + } + const vercelConnectionFormId = `vercel-connection-form-${connection.id}` return ( @@ -119,76 +151,127 @@ const VercelIntegrationConnectionForm = ({ </Alert> ) : ( <div> - <h5 className="text-foreground "> - Sync environment variables for selected target environments - </h5> + <div className="space-y-1 mb-4"> + <h5 className="text-foreground">Sync environment variables to Vercel</h5> + <p className="text-sm text-foreground-light"> + Choose which Vercel environments receive this project's{' '} + <span className="text-foreground">production</span> credentials. Most projects + only need <code>production</code>. + </p> + <p className="text-sm text-foreground-light"> + With{' '} + <InlineLink href={`/project/${project?.ref}/branches`}>Branching</InlineLink>{' '} + enabled, leave Preview and Development off: Branching gives each preview branch + its own database and syncs those credentials to your Vercel Preview deployments. + </p> + </div> - <FormField - control={form.control} - name="environmentVariablesProduction" - render={({ field }) => ( - <FormItem className="space-y-0 flex gap-x-4"> - <FormControl> - <Switch - disabled={disabled} - className="mt-1" - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div> - <FormLabel className="!text">Production</FormLabel> - <FormDescription className="text-xs text-foreground-lighter"> - Sync environment variables for <code>production</code> environment. - </FormDescription> - </div> - </FormItem> + <div className="space-y-4"> + <FormField + control={form.control} + name="environmentVariablesProduction" + render={({ field }) => ( + <FormItemLayout + layout="flex-row-reverse" + label="Production" + description={ + <> + Syncs to the Vercel <code>production</code> env. + </> + } + > + <FormControl> + <Switch + disabled={disabled} + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItemLayout> + )} + /> + <FormField + control={form.control} + name="environmentVariablesPreview" + render={({ field }) => ( + <FormItemLayout + layout="flex-row-reverse" + label="Preview" + description={ + <> + Syncs to the Vercel <code>preview</code> env. + </> + } + > + <FormControl> + <Switch + disabled={disabled} + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItemLayout> + )} + /> + <FormField + control={form.control} + name="environmentVariablesDevelopment" + render={({ field }) => ( + <FormItemLayout + layout="flex-row-reverse" + label="Development" + description={ + <> + Syncs to the Vercel <code>development</code> env. + </> + } + > + <FormControl> + <Switch + disabled={disabled} + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItemLayout> + )} + /> + + {isSyncingNonProdEnvs && ( + <Admonition + type="warning" + title={ + isBranchingEnabled + ? 'Not recommended with Branching' + : 'These environments will use production credentials' + } + > + <p> + Preview and Development sync your production credentials, including the + service role key and database password, to those Vercel environments. + </p> + {isBranchingEnabled ? ( + <p> + <InlineLink href={`/project/${project?.ref}/branches`}> + Branching + </InlineLink>{' '} + already provisions isolated credentials for each preview branch. With + Preview enabled, deployments use production credentials until a branch + finishes provisioning, which can affect production data. We recommend + leaving Preview and Development off. + </p> + ) : ( + <p> + To give preview deployments their own isolated credentials, use{' '} + <InlineLink href={`/project/${project?.ref}/branches`}> + Branching + </InlineLink>{' '} + instead. + </p> + )} + </Admonition> )} - /> - <FormField - control={form.control} - name="environmentVariablesPreview" - render={({ field }) => ( - <FormItem className="space-y-0 flex gap-x-4"> - <FormControl> - <Switch - disabled={disabled} - className="mt-1" - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div> - <FormLabel className="!text">Preview</FormLabel> - <FormDescription className="text-xs text-foreground-lighter"> - Sync environment variables for <code>preview</code> environment. - </FormDescription> - </div> - </FormItem> - )} - /> - <FormField - control={form.control} - name="environmentVariablesDevelopment" - render={({ field }) => ( - <FormItem className="space-y-0 flex gap-x-4"> - <FormControl> - <Switch - disabled={disabled} - className="mt-1" - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div> - <FormLabel className="!text">Development</FormLabel> - <FormDescription className="text-xs text-foreground-lighter"> - Sync environment variables for <code>development</code> environment. - </FormDescription> - </div> - </FormItem> - )} - /> + </div> </div> )} </div> @@ -268,6 +351,49 @@ const VercelIntegrationConnectionForm = ({ /> </div> </form> + + <ConfirmationModal + visible={showConfirmation} + variant="warning" + size="medium" + loading={isPending} + title="Sync production credentials?" + confirmLabel="Sync credentials" + confirmLabelLoading="Saving" + onCancel={() => { + setShowConfirmation(false) + setPendingValues(null) + }} + onConfirm={() => { + if (pendingValues) performUpdate(pendingValues) + }} + alert={{ + title: 'These environments will use production credentials', + description: + 'Including the project ref, API URL, anon and service role keys, and database connection strings.', + }} + > + <div className="space-y-3 text-sm text-foreground-light"> + <p> + This syncs your production credentials to the{' '} + <span className="text-foreground">{selectedNonProdTargets.join(' and ')}</span>{' '} + environment{selectedNonProdTargets.length > 1 ? 's' : ''} of your connected Vercel + project. + </p> + {isBranchingEnabled ? ( + <p> + Branching already provisions isolated credentials for each preview branch. With this + enabled, deployments use production credentials until a branch finishes provisioning, + which can affect production data. We recommend leaving it off. + </p> + ) : ( + <p> + To use isolated credentials for preview deployments instead, enable{' '} + <InlineLink href={`/project/${project?.ref}/branches`}>Branching</InlineLink>. + </p> + )} + </div> + </ConfirmationModal> </Form> ) } diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index 4b07624a1fd..7e0c4a775ff 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -22,7 +22,7 @@ import { IntegrationInstallation, } from '@/components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only' import { useIntegrationsVercelInstalledConnectionDeleteMutation } from '@/data/integrations/integrations-vercel-installed-connection-delete-mutation' import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query' diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx index a004c1a827f..8dbed9ecbb8 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx @@ -11,7 +11,7 @@ import { DropdownMenuTrigger, Separator, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ErrorCodeDialog } from '../ErrorCodeDialog' import type { LogSearchCallback, PreviewLogData } from '../Logs.types' diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index 6d10869354f..e7f65e2e3ee 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -512,7 +512,7 @@ export const LogTable = ({ )} <Button title="run-logs-query" - variant={hasEditorValue ? 'primary' : 'alternative'} + variant="primary" disabled={!hasEditorValue} onClick={onRun} iconRight={<Play size={12} />} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx index 1429d103166..ac160baa964 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx @@ -2,10 +2,11 @@ import { useParams } from 'common' import { SqlEditor } from 'icons' import { Edit, Trash } from 'lucide-react' import { useRouter } from 'next/router' +import { parseAsString, useQueryStates } from 'nuqs' import { useState } from 'react' import { toast } from 'sonner' import { DropdownMenuItem, DropdownMenuSeparator } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { UpdateSavedQueryModal } from './Logs.UpdateSavedQueryModal' import { LogsSidebarItem } from './SidebarV2/SidebarItem' @@ -24,15 +25,26 @@ interface SavedQueriesItemProps { } } -const SavedQueriesItem = ({ item }: SavedQueriesItemProps) => { +export const SavedQueriesItem = ({ item }: SavedQueriesItemProps) => { const router = useRouter() - const { ref } = useParams() + const { ref, queryId } = useParams() const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false) const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false) + const [_, setParams] = useQueryStates({ + queryId: parseAsString.withDefault(''), + q: parseAsString.withDefault(''), + s: parseAsString.withDefault(''), + its: parseAsString.withDefault(''), + ite: parseAsString.withDefault(''), + }) + const { mutate: deleteContent } = useContentDeleteMutation({ - onSuccess: () => { + onSuccess: (_, vars) => { setShowConfirmModal(false) + if (queryId && vars.ids.includes(queryId)) { + setParams({ queryId: '', q: '', s: '', its: '', ite: '' }) + } toast.success('Successfully deleted query') }, onError: (error) => { @@ -94,15 +106,13 @@ const SavedQueriesItem = ({ item }: SavedQueriesItemProps) => { </DropdownMenuItem> </> } - ></LogsSidebarItem> + /> <ConfirmationModal variant="destructive" visible={showConfirmModal} confirmLabel="Delete query" title="Confirm to delete saved query" - onCancel={() => { - setShowConfirmModal(false) - }} + onCancel={() => setShowConfirmModal(false)} onConfirm={onConfirmDelete} > <p className="text-sm text-foreground-light"> @@ -121,5 +131,3 @@ const SavedQueriesItem = ({ item }: SavedQueriesItemProps) => { </> ) } - -export default SavedQueriesItem diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts index 55103491782..ab6671faf79 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -286,8 +286,159 @@ limit 100; }, ] +// ClickHouse rewrites of the custom BigQuery templates above, used when the OTEL +// logs engine is on. Keyed by template label. Simple-mode templates are plain +// event_message searches that work unchanged, so they have no entry here. +const OTEL_TEMPLATE_SEARCH_STRINGS: Record<string, string> = { + 'Commits By User': `select + log_attributes['parsed.user_name'] as user_name, + count() as count +from logs +where source = 'postgres_logs' + and event_message like '%COMMIT%' +group by user_name +order by count desc +limit 100`, + 'Metadata IP': `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'] != '' +order by timestamp desc +limit 100`, + 'Requests by Geography': `select + log_attributes['request.cf.country'] as country, + count() as count +from logs +where source = 'edge_logs' +group by country +order by count desc +limit 100`, + 'Slow Response Time': `select + timestamp, + event_message, + toInt32OrZero(log_attributes['response.origin_time']) as origin_time +from logs +where source = 'edge_logs' + and toInt32OrZero(log_attributes['response.origin_time']) > 1000 +order by timestamp desc +limit 100`, + '500 Request Codes': `select + timestamp, + event_message, + toInt32OrZero(log_attributes['response.status_code']) as status_code +from logs +where source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) >= 500 +order by timestamp desc +limit 100`, + 'Top Paths': `select + log_attributes['request.path'] as path, + log_attributes['request.search'] as params, + count() as c +from logs +where source = 'edge_logs' +group by path, params +order by c desc +limit 100`, + 'REST Requests': `select + timestamp, + event_message +from logs +where source = 'edge_logs' + and log_attributes['request.path'] like '%rest/v1%' +order by timestamp desc +limit 100`, + Errors: `select + timestamp, + log_attributes['parsed.error_severity'] as error_severity, + event_message +from logs +where source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') +order by timestamp desc +limit 100`, + 'Error Count by User': `select + count() as count, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.error_severity'] as error_severity +from logs +where source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') +group by user_name, error_severity +order by count desc +limit 100`, + 'Auth Endpoint Events': `select + timestamp, + event_message +from logs +where source = 'auth_logs' + and match(event_message, 'level.{3}(info|warning|error|fatal)') +order by timestamp desc +limit 100`, + 'Auth Audit Logs': `select + timestamp, + event_message, + log_attributes +from logs +where source = 'auth_audit_logs' +order by timestamp desc +limit 10`, + 'Storage Object Requests': `select + log_attributes['request.method'] as http_verb, + log_attributes['request.path'] as filepath, + count() as num_requests +from logs +where source = 'edge_logs' + and log_attributes['request.path'] like '%storage/v1/object/%' +group by http_verb, filepath +order by num_requests desc +limit 100`, + 'Storage Egress Requests': `select + log_attributes['request.method'] as http_verb, + log_attributes['request.path'] as filepath, + (log_attributes['response.headers.cf_cache_status'] = 'HIT') as cached, + count() as num_requests +from logs +where source = 'edge_logs' + and ( + log_attributes['request.path'] like '%storage/v1/object/%' + or log_attributes['request.path'] like '%storage/v1/render/%' + ) + and log_attributes['request.method'] = 'GET' +group by http_verb, filepath, cached +order by num_requests desc +limit 100`, + 'Storage Top Cache Misses': `select + log_attributes['request.path'] as path, + log_attributes['request.search'] as search, + count() as count +from logs +where source = 'edge_logs' + and startsWith(log_attributes['request.path'], '/storage/v1/object') + and log_attributes['request.method'] = 'GET' + and log_attributes['response.headers.cf_cache_status'] in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') +group by path, search +order by count desc +limit 100`, +} + +/** + * Returns the log templates for the active engine. On the OTEL/ClickHouse engine + * the custom templates, written for BigQuery, are swapped for their ClickHouse + * rewrites; everything else is returned unchanged. + */ +export function getLogsTemplates(useOtel: boolean): LogTemplate[] { + if (!useOtel) return TEMPLATES + return TEMPLATES.map((template) => { + const otelSearchString = template.label && OTEL_TEMPLATE_SEARCH_STRINGS[template.label] + return otelSearchString ? { ...template, searchString: otelSearchString } : template + }) +} + type SqlFilterFn = (value: any) => SafeLogSqlFragment -type SqlFilterEntry = SafeLogSqlFragment | SqlFilterFn +export type SqlFilterEntry = SafeLogSqlFragment | SqlFilterFn const _SQL_FILTER_COMMON: Record<string, SqlFilterEntry> = { search_query: (value: string) => diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.test.ts new file mode 100644 index 00000000000..3d234244ab5 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' + +import { otelFieldsFromKeys, toOtelFieldSchemas, type LogFieldSchema } from './Logs.fieldReference' + +const edgeSchema: LogFieldSchema = { + name: 'API Gateway', + reference: 'edge_logs', + fields: [ + { path: 'id', type: 'string' }, + { path: 'timestamp', type: 'datetime' }, + { path: 'event_message', type: 'string' }, + { path: 'identifier', type: 'string' }, + { path: 'metadata.request.method', type: 'string' }, + { path: 'metadata.request.cf.asn', type: 'number' }, + ], +} + +describe('toOtelFieldSchemas', () => { + it('keeps source name and reference', () => { + const [otel] = toOtelFieldSchemas([edgeSchema]) + expect(otel.name).toBe('API Gateway') + expect(otel.reference).toBe('edge_logs') + }) + + it('keeps id/timestamp/event_message as real columns and adds source + severity_text', () => { + const [otel] = toOtelFieldSchemas([edgeSchema]) + const paths = otel.fields.map((f) => f.path) + expect(paths.slice(0, 5)).toEqual([ + 'id', + 'timestamp', + 'event_message', + 'severity_text', + 'source', + ]) + }) + + it('moves non-column fields into log_attributes, dropping the metadata root', () => { + const [otel] = toOtelFieldSchemas([edgeSchema]) + const paths = otel.fields.map((f) => f.path) + expect(paths).toContain("log_attributes['identifier']") + expect(paths).toContain("log_attributes['request.method']") + expect(paths).toContain("log_attributes['request.cf.asn']") + expect(paths).not.toContain('metadata.request.method') + expect(paths).not.toContain('identifier') + }) + + it('preserves the original field type for casting hints', () => { + const [otel] = toOtelFieldSchemas([edgeSchema]) + const asn = otel.fields.find((f) => f.path === "log_attributes['request.cf.asn']") + expect(asn?.type).toBe('number') + }) +}) + +describe('otelFieldsFromKeys', () => { + it('starts with the five base OTEL columns', () => { + const fields = otelFieldsFromKeys(['request.cf.asn']) + const paths = fields.map((f) => f.path) + expect(paths.slice(0, 5)).toEqual([ + 'id', + 'timestamp', + 'event_message', + 'severity_text', + 'source', + ]) + }) + + it('maps discovered keys to log_attributes lookups', () => { + const fields = otelFieldsFromKeys(['request.cf.asn', 'response.headers.x-foo']) + const paths = fields.map((f) => f.path) + expect(paths).toContain("log_attributes['request.cf.asn']") + expect(paths).toContain("log_attributes['response.headers.x-foo']") + }) + + it('excludes keys that are real OTEL columns', () => { + const fields = otelFieldsFromKeys([ + 'id', + 'timestamp', + 'event_message', + 'severity_text', + 'source', + 'trace_id', + ]) + const paths = fields.map((f) => f.path) + for (const column of ['id', 'timestamp', 'event_message', 'severity_text', 'source']) { + expect(paths.filter((p) => p === column)).toHaveLength(1) + expect(paths).not.toContain(`log_attributes['${column}']`) + } + expect(paths).toContain("log_attributes['trace_id']") + }) + + it('escapes quotes and backslashes in discovered keys', () => { + const fields = otelFieldsFromKeys(["weird'key", 'back\\slash']) + const paths = fields.map((f) => f.path) + expect(paths).toContain("log_attributes['weird''key']") + expect(paths).toContain("log_attributes['back\\\\slash']") + }) + + it('returns only base fields for an empty key list', () => { + const fields = otelFieldsFromKeys([]) + expect(fields).toHaveLength(5) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.ts new file mode 100644 index 00000000000..3f89c77b372 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.ts @@ -0,0 +1,42 @@ +import { logConstants } from 'shared-data' + +export type LogFieldSchema = (typeof logConstants.schemas)[number] +type LogField = LogFieldSchema['fields'][number] + +const OTEL_BASE_FIELDS: LogField[] = [ + { path: 'id', type: 'string' }, + { path: 'timestamp', type: 'datetime' }, + { path: 'event_message', type: 'string' }, + { path: 'severity_text', type: 'string' }, + { path: 'source', type: 'string' }, +] + +const OTEL_COLUMN_PATHS = new Set(OTEL_BASE_FIELDS.map(({ path }) => path)) + +const toOtelAttributeKey = (path: string) => path.replace(/^metadata\./, '') + +const toLogAttributePath = (key: string) => + `log_attributes['${key.replace(/\\/g, '\\\\').replace(/'/g, "''")}']` + +const toOtelFieldSchema = (schema: LogFieldSchema): LogFieldSchema => ({ + ...schema, + fields: [ + ...OTEL_BASE_FIELDS, + ...schema.fields + .filter((field) => !OTEL_COLUMN_PATHS.has(field.path)) + .map((field) => ({ + path: toLogAttributePath(toOtelAttributeKey(field.path)), + type: field.type, + })), + ], +}) + +export const toOtelFieldSchemas = (schemas: LogFieldSchema[]): LogFieldSchema[] => + schemas.map(toOtelFieldSchema) + +export function otelFieldsFromKeys(keys: string[]): LogFieldSchema['fields'] { + const attributeFields = keys + .filter((key) => !OTEL_COLUMN_PATHS.has(key)) + .map((key) => ({ path: toLogAttributePath(key), type: 'string' as const })) + return [...OTEL_BASE_FIELDS, ...attributeFields] +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.templates.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.templates.test.ts new file mode 100644 index 00000000000..f0ee03d2768 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.templates.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { getLogsTemplates, TEMPLATES } from './Logs.constants' + +const customTemplates = TEMPLATES.filter((t) => t.mode === 'custom') +const bigQueryConstructs = + /unnest|cross join|left join|regexp_contains|cast\(\s*timestamp|count\(\*\)/i + +describe('getLogsTemplates', () => { + it('returns the original templates when the OTEL engine is off', () => { + expect(getLogsTemplates(false)).toBe(TEMPLATES) + }) + + it('gives every custom template a ClickHouse rewrite when OTEL is on', () => { + const otel = getLogsTemplates(true) + for (const template of customTemplates) { + const rewritten = otel.find((t) => t.label === template.label) + expect(rewritten, `missing OTEL rewrite for "${template.label}"`).toBeDefined() + expect( + rewritten!.searchString, + `"${template.label}" was not rewritten for ClickHouse` + ).not.toBe(template.searchString) + } + }) + + it('produces ClickHouse SQL with no BigQuery constructs', () => { + const otel = getLogsTemplates(true) + for (const template of otel.filter((t) => t.mode === 'custom')) { + expect(template.searchString, `"${template.label}" still targets logs table`).toMatch( + /from logs/i + ) + expect(template.searchString, `"${template.label}" filters by source`).toMatch(/source\s*=/i) + expect( + template.searchString, + `"${template.label}" still contains a BigQuery construct` + ).not.toMatch(bigQueryConstructs) + } + }) + + it('leaves simple-mode templates unchanged on the OTEL engine', () => { + const otel = getLogsTemplates(true) + for (const template of TEMPLATES.filter((t) => t.mode === 'simple')) { + const same = otel.find((t) => t.label === template.label) + expect(same!.searchString).toBe(template.searchString) + } + }) + + it('maps nested BigQuery fields to the right log_attributes keys', () => { + const otel = getLogsTemplates(true) + const byLabel = (label: string) => otel.find((t) => t.label === label)!.searchString + + expect(byLabel('Requests by Geography')).toContain("log_attributes['request.cf.country']") + expect(byLabel('Metadata IP')).toContain("log_attributes['request.headers.x_real_ip']") + expect(byLabel('Errors')).toContain("log_attributes['parsed.error_severity']") + expect(byLabel('Slow Response Time')).toContain( + "toInt32OrZero(log_attributes['response.origin_time'])" + ) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts index f29dc87a703..38e6d799807 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -3,7 +3,7 @@ import React from 'react' import type { Datum } from '@/components/ui/Charts/Charts.types' -interface Metadata { +export interface Metadata { [key: string]: string | number | Object | Object[] | any } diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.test.ts new file mode 100644 index 00000000000..cddce409337 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.test.ts @@ -0,0 +1,804 @@ +import { describe, expect, it } from 'vitest' + +import { LogsTableName } from './Logs.constants' +import { + genChartQueryOtel, + genCountQueryOtel, + genDefaultQueryOtel, + genSingleLogQueryOtel, + mapOtelPreviewRow, + mapOtelSingleLogToLegacy, + otelTimestampToMicros, +} from './Logs.utils.otel' +import type { OtelLogRow } from '@/data/logs/otel-inspection.utils' +import { formatSql } from '@/lib/formatSql' + +// Format generated SQL so snapshots are deterministic and full strings can be compared. +const fmt = (fragment: string) => formatSql(fragment) + +describe('genDefaultQueryOtel', () => { + it('targets the OTEL logs table by source and aliases postgres columns', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.POSTGRES, {}, 100))).toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['postgres_logs'] + select + id, + timestamp, + event_message, + log_attributes['identifier'] as identifier, + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint + from + logs + where + source = 'postgres_logs' + order by + timestamp desc + limit + 100" + `) + }) + + it('aliases edge columns to the leaf names the api renderer reads', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.EDGE, {}, 50))).toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['edge_logs'] + select + id, + timestamp, + event_message, + log_attributes['identifier'] as identifier, + log_attributes['request.method'] as method, + log_attributes['request.path'] as path, + log_attributes['request.search'] as search, + log_attributes['response.status_code'] as status_code + from + logs + where + source = 'edge_logs' + order by + timestamp desc + limit + 50" + `) + }) + + it('maps pg_cron onto postgres_logs with the cron predicate', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.PG_CRON, {}))).toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['pg_cron_logs'] + select + id, + timestamp, + event_message, + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.query'] as query + from + logs + where + source = 'postgres_logs' + and ( + log_attributes['parsed.application_name'] = 'pg_cron' + or event_message ilike '%cron job%' + ) + order by + timestamp desc + limit + 100" + `) + }) + + it('selects only id/timestamp/event_message for tables without a column map', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.MULTIGRES, {}))).toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['multigres_logs'] + select + id, + timestamp, + event_message + from + logs + where + source = 'multigres_logs' + order by + timestamp desc + limit + 100" + `) + }) + + it('translates an edge status_code filter to a ClickHouse predicate', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.EDGE, { status_code: { error: true } }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['edge_logs'] + select + id, + timestamp, + event_message, + log_attributes['identifier'] as identifier, + log_attributes['request.method'] as method, + log_attributes['request.path'] as path, + log_attributes['request.search'] as search, + log_attributes['response.status_code'] as status_code + from + logs + where + source = 'edge_logs' + and ( + toInt32OrZero (log_attributes['response.status_code']) between 500 and 599 + ) + order by + timestamp desc + limit + 100" + `) + }) + + it('translates a search_query filter to a case-insensitive event_message match', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.POSTGRES, { search_query: 'deadlock' }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['postgres_logs'] + select + id, + timestamp, + event_message, + log_attributes['identifier'] as identifier, + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint + from + logs + where + source = 'postgres_logs' + and (event_message ilike '%deadlock%') + order by + timestamp desc + limit + 100" + `) + }) + + it('uses the shared auth severity condition for the severity.error filter', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.AUTH, { severity: { error: true } }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['auth_logs'] + select + id, + timestamp, + event_message, + log_attributes['level'] as level, + log_attributes['status'] as status, + log_attributes['path'] as path, + log_attributes['msg'] as msg, + log_attributes['error'] as error + from + logs + where + source = 'auth_logs' + and ( + log_attributes['level'] in ('error', 'fatal') + or toInt32OrZero (log_attributes['status']) >= 500 + ) + order by + timestamp desc + limit + 100" + `) + }) + + it('falls back to a log_attributes equality for unknown filter keys', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.AUTH, { trace_id: 'abc-123' }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['auth_logs'] + select + id, + timestamp, + event_message, + log_attributes['level'] as level, + log_attributes['status'] as status, + log_attributes['path'] as path, + log_attributes['msg'] as msg, + log_attributes['error'] as error + from + logs + where + source = 'auth_logs' + and (log_attributes['trace_id'] = 'abc-123') + order by + timestamp desc + limit + 100" + `) + }) + + it('combines multiple filters with AND alongside the source predicate', () => { + expect( + fmt( + genDefaultQueryOtel(LogsTableName.EDGE, { + status_code: { error: true }, + method: { get: true }, + }) + ) + ).toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['edge_logs'] + select + id, + timestamp, + event_message, + log_attributes['identifier'] as identifier, + log_attributes['request.method'] as method, + log_attributes['request.path'] as path, + log_attributes['request.search'] as search, + log_attributes['response.status_code'] as status_code + from + logs + where + source = 'edge_logs' + and ( + toInt32OrZero (log_attributes['response.status_code']) between 500 and 599 + ) + and (log_attributes['request.method'] = 'GET') + order by + timestamp desc + limit + 100" + `) + }) +}) + +describe('genCountQueryOtel', () => { + it('counts rows for the source with translated auth filters', () => { + expect(fmt(genCountQueryOtel(LogsTableName.AUTH, { status_code: { server_error: true } }))) + .toMatchInlineSnapshot(` + "-- Logs Count Query (otel) ['auth_logs'] + select + count() as count + from + logs + where + source = 'auth_logs' + and ( + toInt32OrZero (log_attributes['status']) between 500 and 599 + )" + `) + }) +}) + +describe('genChartQueryOtel', () => { + const params = { + iso_timestamp_start: '2024-01-01T00:00:00.000Z', + iso_timestamp_end: '2024-01-01T00:30:00.000Z', + } + + it('buckets by minute for short ranges and emits ok/error/warning counts', () => { + expect(fmt(genChartQueryOtel(LogsTableName.EDGE, params, {}))).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['edge_logs'] + select + toStartOfMinute (timestamp) as timestamp, + countIf ( + not ( + ( + toInt32OrZero (log_attributes['response.status_code']) >= 500 + ) + or ( + toInt32OrZero (log_attributes['response.status_code']) between 400 and 499 + ) + ) + ) as ok_count, + countIf ( + toInt32OrZero (log_attributes['response.status_code']) >= 500 + ) as error_count, + countIf ( + toInt32OrZero (log_attributes['response.status_code']) between 400 and 499 + ) as warning_count + from + logs + where + source = 'edge_logs' + group by + timestamp + order by + timestamp asc" + `) + }) + + it('filters the function logs chart by the metadata.function_id override key', () => { + expect( + fmt(genChartQueryOtel(LogsTableName.FUNCTIONS, params, { 'metadata.function_id': 'abc-123' })) + ).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['function_logs'] + select + toStartOfMinute (timestamp) as timestamp, + countIf ( + not ( + (log_attributes['level'] in ('error', 'fatal')) + or (log_attributes['level'] = 'warning') + ) + ) as ok_count, + countIf (log_attributes['level'] in ('error', 'fatal')) as error_count, + countIf (log_attributes['level'] = 'warning') as warning_count + from + logs + where + source = 'function_logs' + and (log_attributes['function_id'] = 'abc-123') + group by + timestamp + order by + timestamp asc" + `) + }) + + it('buckets by hour for ranges longer than 12 hours', () => { + expect( + fmt( + genChartQueryOtel( + LogsTableName.POSTGRES, + { + iso_timestamp_start: '2024-01-01T00:00:00.000Z', + iso_timestamp_end: '2024-01-02T00:00:00.000Z', + }, + {} + ) + ) + ).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['postgres_logs'] + select + toStartOfHour (timestamp) as timestamp, + countIf ( + not ( + ( + log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + ) + or ( + log_attributes['parsed.error_severity'] = 'WARNING' + ) + ) + ) as ok_count, + countIf ( + log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + ) as error_count, + countIf ( + log_attributes['parsed.error_severity'] = 'WARNING' + ) as warning_count + from + logs + where + source = 'postgres_logs' + group by + timestamp + order by + timestamp asc" + `) + }) + + it('classifies auth severity by level and status, matching the BigQuery conditions', () => { + expect(fmt(genChartQueryOtel(LogsTableName.AUTH, params, {}))).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['auth_logs'] + select + toStartOfMinute (timestamp) as timestamp, + countIf ( + not ( + ( + log_attributes['level'] in ('error', 'fatal') + or toInt32OrZero (log_attributes['status']) >= 500 + ) + or ( + log_attributes['level'] = 'warning' + or toInt32OrZero (log_attributes['status']) between 400 and 499 + ) + ) + ) as ok_count, + countIf ( + log_attributes['level'] in ('error', 'fatal') + or toInt32OrZero (log_attributes['status']) >= 500 + ) as error_count, + countIf ( + log_attributes['level'] = 'warning' + or toInt32OrZero (log_attributes['status']) between 400 and 499 + ) as warning_count + from + logs + where + source = 'auth_logs' + group by + timestamp + order by + timestamp asc" + `) + }) + + it('uses JSONExtractString for multigres severity (event_message is a JSON string)', () => { + expect(fmt(genChartQueryOtel(LogsTableName.MULTIGRES, params, {}))).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['multigres_logs'] + select + toStartOfMinute (timestamp) as timestamp, + countIf ( + not ( + ( + JSONExtractString (event_message, 'level') in ('ERROR', 'FATAL', 'PANIC') + ) + or ( + JSONExtractString (event_message, 'level') in ('WARN', 'WARNING') + ) + ) + ) as ok_count, + countIf ( + JSONExtractString (event_message, 'level') in ('ERROR', 'FATAL', 'PANIC') + ) as error_count, + countIf ( + JSONExtractString (event_message, 'level') in ('WARN', 'WARNING') + ) as warning_count + from + logs + where + source = 'multigres_logs' + group by + timestamp + order by + timestamp asc" + `) + }) + + it('counts pg_cron errors but never warnings, matching BigQuery (no pg_cron warning case)', () => { + expect(fmt(genChartQueryOtel(LogsTableName.PG_CRON, params, {}))).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['pg_cron_logs'] + select + toStartOfMinute (timestamp) as timestamp, + countIf ( + not ( + ( + log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + ) + or (0) + ) + ) as ok_count, + countIf ( + log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + ) as error_count, + countIf (0) as warning_count + from + logs + where + source = 'postgres_logs' + and ( + log_attributes['parsed.application_name'] = 'pg_cron' + or event_message ilike '%cron job%' + ) + group by + timestamp + order by + timestamp asc" + `) + }) + + it('emits constant-false severity for tables without a severity concept', () => { + expect(fmt(genChartQueryOtel(LogsTableName.STORAGE, params, {}))).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['storage_logs'] + select + toStartOfMinute (timestamp) as timestamp, + countIf ( + not ( + (0) + or (0) + ) + ) as ok_count, + countIf (0) as error_count, + countIf (0) as warning_count + from + logs + where + source = 'storage_logs' + group by + timestamp + order by + timestamp asc" + `) + }) + + it('defaults the chart bucket to minute when no time range is given', () => { + expect(fmt(genChartQueryOtel(LogsTableName.EDGE, {}, {}))).toMatchInlineSnapshot(` + "-- Logs Chart Query (otel) ['edge_logs'] + select + toStartOfMinute (timestamp) as timestamp, + countIf ( + not ( + ( + toInt32OrZero (log_attributes['response.status_code']) >= 500 + ) + or ( + toInt32OrZero (log_attributes['response.status_code']) between 400 and 499 + ) + ) + ) as ok_count, + countIf ( + toInt32OrZero (log_attributes['response.status_code']) >= 500 + ) as error_count, + countIf ( + toInt32OrZero (log_attributes['response.status_code']) between 400 and 499 + ) as warning_count + from + logs + where + source = 'edge_logs' + group by + timestamp + order by + timestamp asc" + `) + }) +}) + +describe('genSingleLogQueryOtel', () => { + it('fetches a single row by id with raw attributes', () => { + expect(fmt(genSingleLogQueryOtel('123e4567-e89b-12d3-a456-426614174000'))) + .toMatchInlineSnapshot(` + "-- Single Log Query (otel) + select + id, + timestamp, + event_message, + source, + severity_text, + log_attributes + from + logs + where + id = '123e4567-e89b-12d3-a456-426614174000' + limit + 1" + `) + }) + + it('rejects a non-uuid id', () => { + expect(() => genSingleLogQueryOtel("1' OR '1'='1")).toThrow('Invalid logId') + }) +}) + +describe('OTEL filter translation', () => { + it('translates the database filter for postgres', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.POSTGRES, { database: 'replica-1' }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['postgres_logs'] + select + id, + timestamp, + event_message, + log_attributes['identifier'] as identifier, + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint + from + logs + where + source = 'postgres_logs' + and (log_attributes['identifier'] = 'replica-1') + order by + timestamp desc + limit + 100" + `) + }) + + it('translates the database filter for supavisor', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.SUPAVISOR, { database: 'proj' }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['supavisor_logs'] + select + id, + timestamp, + event_message + from + logs + where + source = 'supavisor_logs' + and (log_attributes['project'] like 'proj%') + order by + timestamp desc + limit + 100" + `) + }) + + it('drops the metadata root from an override key for function logs', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.FUNCTIONS, { 'metadata.function_id': 'abc-123' }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['function_logs'] + select + id, + timestamp, + event_message, + log_attributes['event_type'] as event_type, + log_attributes['function_id'] as function_id, + log_attributes['execution_id'] as execution_id, + log_attributes['level'] as level + from + logs + where + source = 'function_logs' + and (log_attributes['function_id'] = 'abc-123') + order by + timestamp desc + limit + 100" + `) + }) + + it('translates the etl pipeline_id filter', () => { + expect(fmt(genDefaultQueryOtel(LogsTableName.ETL, { pipeline_id: '42' }))) + .toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['etl_replication_logs'] + select + id, + timestamp, + event_message + from + logs + where + source = 'etl_replication_logs' + and (log_attributes['pipeline_id'] = '42') + order by + timestamp desc + limit + 100" + `) + }) + + it('quotes a numeric etl pipeline_id so it compares as a string', () => { + // The replication "View logs" link puts pipeline_id in the `f` param as a + // number; JSON.parse keeps it numeric, so model that path here. + const filters = JSON.parse('{ "pipeline_id": 42 }') + expect(fmt(genDefaultQueryOtel(LogsTableName.ETL, filters))).toMatchInlineSnapshot(` + "-- Logs Preview Query (otel) ['etl_replication_logs'] + select + id, + timestamp, + event_message + from + logs + where + source = 'etl_replication_logs' + and (log_attributes['pipeline_id'] = '42') + order by + timestamp desc + limit + 100" + `) + }) +}) + +describe('otelTimestampToMicros', () => { + it('converts an ISO string to microseconds since epoch', () => { + const micros = otelTimestampToMicros('2024-01-01T00:00:00.000Z') + expect(micros).toBe(Date.UTC(2024, 0, 1) * 1000) + }) + + it('parses a space-separated ClickHouse datetime as UTC', () => { + const micros = otelTimestampToMicros('2024-01-01 00:00:00') + expect(micros).toBe(Date.UTC(2024, 0, 1) * 1000) + }) + + it('passes through numeric microseconds', () => { + const micros = otelTimestampToMicros(1704067200000000) + expect(micros).toBe(1704067200000000) + }) + + it('parses numeric microseconds supplied as a string', () => { + const micros = otelTimestampToMicros('1704067200000000') + expect(micros).toBe(1704067200000000) + }) +}) + +describe('mapOtelPreviewRow', () => { + it('normalizes the timestamp to a microsecond number and keeps other columns', () => { + const row = mapOtelPreviewRow({ + id: 'abc', + timestamp: '2024-01-01T00:00:00.000Z', + event_message: 'hello', + status_code: '200', + }) + expect(row.timestamp).toBe(Date.UTC(2024, 0, 1) * 1000) + expect(row.event_message).toBe('hello') + expect((row as any).status_code).toBe('200') + }) +}) + +describe('mapOtelSingleLogToLegacy', () => { + const baseRow: OtelLogRow = { + id: 'log-1', + timestamp: '2024-01-01T00:00:00.000Z', + source: 'edge_logs', + event_message: 'GET /rest/v1/foo', + log_attributes: { + 'request.method': 'GET', + 'request.path': '/rest/v1/foo', + 'request.search': '?select=*', + 'request.headers.user_agent': 'curl/8', + 'response.status_code': '200', + // Real OTEL gateway key is `sb_error_code` (BigQuery used `x_sb_error_code`). + 'response.headers.sb_error_code': 'none', + }, + } + + it('reconstructs the nested metadata shape the api panel reads', () => { + const log = mapOtelSingleLogToLegacy(baseRow, 'api') as any + expect(log.metadata[0].request[0].method).toBe('GET') + expect(log.metadata[0].request[0].path).toBe('/rest/v1/foo') + expect(log.metadata[0].request[0].search).toBe('?select=*') + expect(log.metadata[0].request[0].headers[0].user_agent).toBe('curl/8') + expect(log.metadata[0].response[0].status_code).toBe('200') + expect(log.metadata[0].response[0].headers[0].x_sb_error_code).toBe('none') + expect(log.timestamp).toBe(Date.UTC(2024, 0, 1) * 1000) + }) + + it('falls back to the legacy x_sb_error_code key when the OTEL key is absent', () => { + const row: OtelLogRow = { + ...baseRow, + log_attributes: { + 'request.method': 'GET', + 'response.status_code': '500', + // Only the legacy BigQuery-style key is present. + 'response.headers.x_sb_error_code': 'pgrst_error', + }, + } + const log = mapOtelSingleLogToLegacy(row, 'api') as any + expect(log.metadata[0].response[0].headers[0].x_sb_error_code).toBe('pgrst_error') + }) + + it('reconstructs the nested parsed metadata for the database panel', () => { + const row: OtelLogRow = { + id: 'log-2', + timestamp: '2024-01-01T00:00:00.000Z', + source: 'postgres_logs', + event_message: 'ERROR: deadlock detected', + log_attributes: { + 'parsed.hint': 'retry', + 'parsed.detail': 'process 1 waits', + 'parsed.query': 'UPDATE foo', + }, + } + const log = mapOtelSingleLogToLegacy(row, 'database') as any + expect(log.metadata[0].parsed[0].hint).toBe('retry') + expect(log.metadata[0].parsed[0].detail).toBe('process 1 waits') + expect(log.metadata[0].parsed[0].query).toBe('UPDATE foo') + }) + + it('returns a flat row with spread attributes for other query types', () => { + const row: OtelLogRow = { + id: 'log-3', + timestamp: '2024-01-01T00:00:00.000Z', + source: 'storage_logs', + event_message: 'object uploaded', + log_attributes: { level: 'info' }, + } + const log = mapOtelSingleLogToLegacy(row, 'storage') as any + expect(log.metadata).toBeUndefined() + expect(log.level).toBe('info') + expect(log.event_message).toBe('object uploaded') + }) + + it('returns a safe empty row for an invalid (non-object) input', () => { + const log = mapOtelSingleLogToLegacy(undefined) as any + expect(log.id).toBe('') + expect(log.metadata).toBeUndefined() + }) + + it('fills api metadata with null when attributes are missing', () => { + const log = mapOtelSingleLogToLegacy({ log_attributes: {} }, 'api') as any + expect(log.metadata[0].request[0].method).toBeNull() + expect(log.metadata[0].response[0].status_code).toBeNull() + expect(log.metadata[0].response[0].headers[0].x_sb_error_code).toBeNull() + }) + + it('falls back to the legacy x_sb_error_code key for api rows', () => { + const log = mapOtelSingleLogToLegacy( + { log_attributes: { 'response.headers.x_sb_error_code': 'pgrst' } }, + 'api' + ) as any + expect(log.metadata[0].response[0].headers[0].x_sb_error_code).toBe('pgrst') + }) + + it('fills database metadata with null when attributes are missing', () => { + const log = mapOtelSingleLogToLegacy({ log_attributes: {} }, 'database') as any + expect(log.metadata[0].parsed[0].hint).toBeNull() + expect(log.metadata[0].parsed[0].detail).toBeNull() + expect(log.metadata[0].parsed[0].query).toBeNull() + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.ts new file mode 100644 index 00000000000..9e999f6e455 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.ts @@ -0,0 +1,399 @@ +import dayjs from 'dayjs' +import { z } from 'zod' + +import { LogsTableName, type SqlFilterEntry } from './Logs.constants' +import type { Filters, LogData, LogsEndpointParams, Metadata, QueryType } from './Logs.types' +import { buildWhereClauses } from './Logs.utils' +import { parseOtelTimestamp } from '@/data/logs/otel-inspection.utils' +import { + joinSqlFragments, + analyticsLiteral as lit, + safeSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' + +// Builds ClickHouse SQL for the OTEL `logs` table (one table for all sources, +// fields in the `log_attributes` map). Mirrors the BigQuery builders in +// Logs.utils.ts, aliasing columns to the names the row renderers already read. + +const attr = (key: string): SafeLogSqlFragment => safeSql`log_attributes[${lit(key)}]` +const statusAsInt = (key: string): SafeLogSqlFragment => safeSql`toInt32OrZero(${attr(key)})` + +// Severity conditions, shared by the chart and the auth filters so a row is +// classified the same everywhere. +const HTTP_ERROR: SafeLogSqlFragment = safeSql`${statusAsInt('response.status_code')} >= 500` +const HTTP_WARNING: SafeLogSqlFragment = safeSql`${statusAsInt('response.status_code')} BETWEEN 400 AND 499` +const PG_ERROR: SafeLogSqlFragment = safeSql`${attr('parsed.error_severity')} IN ('ERROR', 'FATAL', 'PANIC')` +const PG_WARNING: SafeLogSqlFragment = safeSql`${attr('parsed.error_severity')} = 'WARNING'` +const FN_ERROR: SafeLogSqlFragment = safeSql`${attr('level')} IN ('error', 'fatal')` +const FN_WARNING: SafeLogSqlFragment = safeSql`${attr('level')} = 'warning'` +const MULTIGRES_ERROR: SafeLogSqlFragment = safeSql`JSONExtractString(event_message, 'level') IN ('ERROR', 'FATAL', 'PANIC')` +const MULTIGRES_WARNING: SafeLogSqlFragment = safeSql`JSONExtractString(event_message, 'level') IN ('WARN', 'WARNING')` +const AUTH_ERROR: SafeLogSqlFragment = safeSql`${attr('level')} IN ('error', 'fatal') OR ${statusAsInt('status')} >= 500` +const AUTH_WARNING: SafeLogSqlFragment = safeSql`${attr('level')} = 'warning' OR ${statusAsInt('status')} BETWEEN 400 AND 499` +const AUTH_INFO: SafeLogSqlFragment = safeSql`NOT (${AUTH_ERROR}) AND NOT (${AUTH_WARNING})` +// Tables with no severity: every row counts as ok. +const NO_MATCH: SafeLogSqlFragment = safeSql`0` + +const PG_CRON_CONDITION: SafeLogSqlFragment = safeSql`(${attr('parsed.application_name')} = 'pg_cron' OR event_message ILIKE '%cron job%')` + +const COMMON_FILTERS: Record<string, SqlFilterEntry> = { + search_query: (value: string) => safeSql`event_message ILIKE ${lit('%' + value + '%')}`, +} +const databaseFilter = (value: string): SafeLogSqlFragment => + safeSql`${attr('identifier')} = ${lit(value)}` + +// ClickHouse filters per table. Tables not listed use COMMON_FILTERS only. +const POSTGRES_FILTERS: Record<string, SqlFilterEntry> = { + ...COMMON_FILTERS, + database: databaseFilter, + 'severity.error': PG_ERROR, + 'severity.noError': safeSql`${attr('parsed.error_severity')} NOT IN ('ERROR', 'FATAL', 'PANIC')`, + 'severity.log': safeSql`${attr('parsed.error_severity')} = 'LOG'`, +} +const STATUS_CODE_FILTERS: Record<string, SqlFilterEntry> = { + 'status_code.error': safeSql`${statusAsInt('response.status_code')} BETWEEN 500 AND 599`, + 'status_code.success': safeSql`${statusAsInt('response.status_code')} BETWEEN 200 AND 299`, + 'status_code.warning': safeSql`${statusAsInt('response.status_code')} BETWEEN 400 AND 499`, +} +const EDGE_FILTERS: Record<string, SqlFilterEntry> = { + ...COMMON_FILTERS, + database: databaseFilter, + ...STATUS_CODE_FILTERS, + 'product.database': safeSql`${attr('request.path')} LIKE '/rest/%' OR ${attr('request.path')} LIKE '/graphql/%'`, + 'product.storage': safeSql`${attr('request.path')} LIKE '/storage/%'`, + 'product.auth': safeSql`${attr('request.path')} LIKE '/auth/%'`, + 'product.realtime': safeSql`${attr('request.path')} LIKE '/realtime/%'`, + 'method.get': safeSql`${attr('request.method')} = 'GET'`, + 'method.post': safeSql`${attr('request.method')} = 'POST'`, + 'method.put': safeSql`${attr('request.method')} = 'PUT'`, + 'method.patch': safeSql`${attr('request.method')} = 'PATCH'`, + 'method.delete': safeSql`${attr('request.method')} = 'DELETE'`, + 'method.options': safeSql`${attr('request.method')} = 'OPTIONS'`, +} +const FUNCTION_LOGS_FILTERS: Record<string, SqlFilterEntry> = { + ...COMMON_FILTERS, + 'severity.error': safeSql`${attr('level')} = 'error'`, + 'severity.notError': safeSql`${attr('level')} != 'error'`, + 'severity.log': safeSql`${attr('level')} = 'log'`, + 'severity.info': safeSql`${attr('level')} = 'info'`, + 'severity.debug': safeSql`${attr('level')} = 'debug'`, + 'severity.warn': safeSql`${attr('level')} = 'warn'`, +} +const AUTH_FILTERS: Record<string, SqlFilterEntry> = { + ...COMMON_FILTERS, + 'severity.error': AUTH_ERROR, + 'severity.warning': AUTH_WARNING, + 'severity.info': AUTH_INFO, + 'status_code.server_error': safeSql`${statusAsInt('status')} BETWEEN 500 AND 599`, + 'status_code.client_error': safeSql`${statusAsInt('status')} BETWEEN 400 AND 499`, + 'status_code.redirection': safeSql`${statusAsInt('status')} BETWEEN 300 AND 399`, + 'status_code.success': safeSql`${statusAsInt('status')} BETWEEN 200 AND 299`, + 'endpoints.admin': safeSql`match(${attr('path')}, '/admin')`, + 'endpoints.signup': safeSql`match(${attr('path')}, '/signup|/invite|/verify')`, + 'endpoints.authentication': safeSql`match(${attr('path')}, '/token|/authorize|/callback|/otp|/magiclink')`, + 'endpoints.recover': safeSql`match(${attr('path')}, '/recover')`, + 'endpoints.user': safeSql`match(${attr('path')}, '/user')`, + 'endpoints.logout': safeSql`match(${attr('path')}, '/logout')`, +} +const SUPAVISOR_FILTERS: Record<string, SqlFilterEntry> = { + ...COMMON_FILTERS, + database: (value: string) => safeSql`${attr('project')} LIKE ${lit(value + '%')}`, +} +const ETL_FILTERS: Record<string, SqlFilterEntry> = { + ...COMMON_FILTERS, + // pipeline_id arrives as a number, but log_attributes values are strings, so + // compare as a string. A numeric literal here is a ClickHouse type error. + pipeline_id: (value: string | number) => safeSql`${attr('pipeline_id')} = ${lit(String(value))}`, +} + +const col = (key: string, alias: SafeLogSqlFragment): SafeLogSqlFragment => + safeSql`${attr(key)} AS ${alias}` + +// What varies per table. Everything but `source` is optional, so a plain +// process-log table is just `{ source }`. +interface OtelSourceDescriptor { + source: string + // Columns beyond id/timestamp/event_message, aliased to the renderer names. + columns?: SafeLogSqlFragment[] + filterTemplates?: Record<string, SqlFilterEntry> + // Chart severity; absent means the table has no error/warning. + error?: SafeLogSqlFragment + warning?: SafeLogSqlFragment + // Always ANDed with the source filter (e.g. the pg_cron subset). + baseCondition?: SafeLogSqlFragment +} + +const OTEL_SOURCES: Record<LogsTableName, OtelSourceDescriptor> = { + [LogsTableName.EDGE]: { + source: 'edge_logs', + columns: [ + col('identifier', safeSql`identifier`), + col('request.method', safeSql`method`), + col('request.path', safeSql`path`), + col('request.search', safeSql`search`), + col('response.status_code', safeSql`status_code`), + ], + filterTemplates: EDGE_FILTERS, + error: HTTP_ERROR, + warning: HTTP_WARNING, + }, + [LogsTableName.POSTGRES]: { + source: 'postgres_logs', + columns: [ + col('identifier', safeSql`identifier`), + col('parsed.error_severity', safeSql`error_severity`), + col('parsed.detail', safeSql`detail`), + col('parsed.hint', safeSql`hint`), + ], + filterTemplates: POSTGRES_FILTERS, + error: PG_ERROR, + warning: PG_WARNING, + }, + [LogsTableName.PG_CRON]: { + source: 'postgres_logs', + columns: [ + col('parsed.error_severity', safeSql`error_severity`), + col('parsed.query', safeSql`query`), + ], + error: PG_ERROR, + // No warning bucket, to match BigQuery (where pg_cron has no warning case). + baseCondition: PG_CRON_CONDITION, + }, + [LogsTableName.FUNCTIONS]: { + source: 'function_logs', + columns: [ + col('event_type', safeSql`event_type`), + col('function_id', safeSql`function_id`), + col('execution_id', safeSql`execution_id`), + col('level', safeSql`level`), + ], + filterTemplates: FUNCTION_LOGS_FILTERS, + error: FN_ERROR, + warning: FN_WARNING, + }, + [LogsTableName.FN_EDGE]: { + source: 'function_edge_logs', + columns: [ + col('response.status_code', safeSql`status_code`), + col('request.method', safeSql`method`), + col('request.pathname', safeSql`pathname`), + col('function_id', safeSql`function_id`), + col('execution_id', safeSql`execution_id`), + col('execution_time_ms', safeSql`execution_time_ms`), + col('deployment_id', safeSql`deployment_id`), + col('version', safeSql`version`), + ], + filterTemplates: { ...COMMON_FILTERS, ...STATUS_CODE_FILTERS }, + error: HTTP_ERROR, + warning: HTTP_WARNING, + }, + [LogsTableName.AUTH]: { + source: 'auth_logs', + columns: [ + col('level', safeSql`level`), + col('status', safeSql`status`), + col('path', safeSql`path`), + col('msg', safeSql`msg`), + col('error', safeSql`error`), + ], + filterTemplates: AUTH_FILTERS, + error: AUTH_ERROR, + warning: AUTH_WARNING, + }, + [LogsTableName.MULTIGRES]: { + source: 'multigres_logs', + error: MULTIGRES_ERROR, + warning: MULTIGRES_WARNING, + }, + [LogsTableName.POSTGREST]: { + source: 'postgrest_logs', + filterTemplates: { ...COMMON_FILTERS, database: databaseFilter }, + }, + [LogsTableName.SUPAVISOR]: { source: 'supavisor_logs', filterTemplates: SUPAVISOR_FILTERS }, + [LogsTableName.ETL]: { source: 'etl_replication_logs', filterTemplates: ETL_FILTERS }, + [LogsTableName.AUTH_AUDIT]: { source: 'auth_audit_logs' }, + [LogsTableName.REALTIME]: { source: 'realtime_logs' }, + [LogsTableName.STORAGE]: { source: 'storage_logs' }, + [LogsTableName.PGBOUNCER]: { source: 'pgbouncer_logs' }, + [LogsTableName.PG_UPGRADE]: { source: 'pg_upgrade_logs' }, +} + +// Fallback for filter keys with no template: `log_attributes[key] = value`. +// Drops the clause on non-scalar or un-encodable input. +const resolveUnknownOtelClause = (dotKey: string, value: unknown): SafeLogSqlFragment | null => { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + return null + } + // OTEL drops the `metadata` root that BigQuery filter keys carry, so a + // `metadata.function_id` override maps to `log_attributes['function_id']`. + const attrKey = dotKey.startsWith('metadata.') ? dotKey.slice('metadata.'.length) : dotKey + try { + return safeSql`${attr(attrKey)} = ${lit(value)}` + } catch { + return null + } +} + +const genOtelWhere = (table: LogsTableName, filters: Filters): SafeLogSqlFragment => { + const desc = OTEL_SOURCES[table] + const conditions: SafeLogSqlFragment[] = [safeSql`source = ${lit(desc.source)}`] + if (desc.baseCondition) conditions.push(desc.baseCondition) + conditions.push( + ...buildWhereClauses( + table, + filters, + desc.filterTemplates ?? COMMON_FILTERS, + resolveUnknownOtelClause + ) + ) + return safeSql`WHERE ${joinSqlFragments(conditions, ' AND ')}` +} + +const genOtelSelectColumns = (table: LogsTableName): SafeLogSqlFragment => { + const columns: SafeLogSqlFragment[] = [ + safeSql`id`, + safeSql`timestamp`, + safeSql`event_message`, + ...(OTEL_SOURCES[table].columns ?? []), + ] + return joinSqlFragments(columns, ', ') +} + +export const genDefaultQueryOtel = ( + table: LogsTableName, + filters: Filters, + limit: number = 100 +): SafeLogSqlFragment => { + return safeSql`-- Logs Preview Query (otel) [${lit(table)}] +SELECT ${genOtelSelectColumns(table)} +FROM logs +${genOtelWhere(table, filters)} +ORDER BY timestamp DESC +LIMIT ${lit(limit)}` +} + +export const genCountQueryOtel = (table: LogsTableName, filters: Filters): SafeLogSqlFragment => { + return safeSql`-- Logs Count Query (otel) [${lit(table)}] +SELECT count() AS count FROM logs ${genOtelWhere(table, filters)}` +} + +// Bucket by minute up to 12h, otherwise by hour (matches BigQuery). +const otelChartTruncFn = (params: LogsEndpointParams): SafeLogSqlFragment => { + const ite = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs() + const its = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs() + const minuteDiff = ite.diff(its, 'minute') + const hourDiff = ite.diff(its, 'hour') + if (minuteDiff > 60 * 12) return safeSql`toStartOfHour` + if (hourDiff > 24 * 3) return safeSql`toStartOfDay` + return safeSql`toStartOfMinute` +} + +export const genChartQueryOtel = ( + table: LogsTableName, + params: LogsEndpointParams, + filters: Filters +): SafeLogSqlFragment => { + const truncFn = otelChartTruncFn(params) + const desc = OTEL_SOURCES[table] + const err = desc.error ?? NO_MATCH + const warn = desc.warning ?? NO_MATCH + // The time range comes from the endpoint params, not the SQL. + return safeSql`-- Logs Chart Query (otel) [${lit(table)}] +SELECT + ${truncFn}(timestamp) AS timestamp, + countIf(NOT ((${err}) OR (${warn}))) AS ok_count, + countIf(${err}) AS error_count, + countIf(${warn}) AS warning_count +FROM logs +${genOtelWhere(table, filters)} +GROUP BY timestamp +ORDER BY timestamp ASC` +} + +// Reject non-uuid ids before putting them in the query (injection guard). +export const genSingleLogQueryOtel = (id: string): SafeLogSqlFragment => { + if (!/^[0-9a-fA-F-]{1,64}$/.test(id)) { + throw new Error('Invalid logId') + } + return safeSql`-- Single Log Query (otel) +SELECT id, timestamp, event_message, source, severity_text, log_attributes +FROM logs +WHERE id = ${lit(id)} +LIMIT 1` +} + +// Renderers and the pagination cursor expect a microsecond number. +export const otelTimestampToMicros = (timestamp: unknown): number => + parseOtelTimestamp(timestamp).getTime() * 1000 + +export const mapOtelPreviewRow = (row: Record<string, any>): LogData => { + return { ...row, timestamp: otelTimestampToMicros(row.timestamp) } as LogData +} + +// The api/database detail panels read a nested `metadata` shape, so rebuild it +// from the flat attributes. Other tables use a flat row. +// TODO: edge logs also carry the JWT/apikey keys, so the panel's API key / role +// rows could be rebuilt here later. +const SINGLE_LOG_METADATA: Partial<Record<QueryType, (attrs: Record<string, string>) => Metadata>> = + { + api: (attrs) => ({ + request: [ + { + method: attrs['request.method'] ?? null, + path: attrs['request.path'] ?? null, + search: attrs['request.search'] ?? null, + headers: [{ user_agent: attrs['request.headers.user_agent'] ?? null }], + }, + ], + response: [ + { + status_code: attrs['response.status_code'] ?? null, + // OTEL uses `sb_error_code`; BigQuery used `x_sb_error_code`. Fall back + // to the old key so the panel works during the migration. + headers: [ + { + x_sb_error_code: + attrs['response.headers.sb_error_code'] ?? + attrs['response.headers.x_sb_error_code'] ?? + null, + }, + ], + }, + ], + }), + database: (attrs) => ({ + parsed: [ + { + hint: attrs['parsed.hint'] ?? null, + detail: attrs['parsed.detail'] ?? null, + query: attrs['parsed.query'] ?? null, + }, + ], + }), + } + +// OTEL rows come back untyped from the analytics endpoint; validate the shape we +// read instead of casting. log_attributes is a string-keyed string map. +const OtelLogRowSchema = z.object({ + id: z.string().optional(), + timestamp: z.union([z.string(), z.number()]).optional(), + event_message: z.string().optional(), + log_attributes: z.record(z.string(), z.string()).optional(), +}) + +export const mapOtelSingleLogToLegacy = (row: unknown, queryType?: QueryType): LogData => { + const parsed = OtelLogRowSchema.safeParse(row) + if (!parsed.success) { + console.error('Unexpected OTEL log row shape:', parsed.error.message) + return { id: '', timestamp: 0, event_message: '' } + } + const { id = '', timestamp, event_message = '', log_attributes = {} } = parsed.data + const base: LogData = { id, timestamp: otelTimestampToMicros(timestamp), event_message } + + const buildMetadata = queryType ? SINGLE_LOG_METADATA[queryType] : undefined + if (buildMetadata) { + return { ...base, metadata: [buildMetadata(log_attributes)] } + } + return { ...base, ...log_attributes } +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 6fa1baa0be2..44be04b8b07 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -11,6 +11,7 @@ import { AUTH_LOG_WARNING_CONDITION, LogsTableName, SQL_FILTER_TEMPLATES, + type SqlFilterEntry, } from './Logs.constants' import type { Filters, LogData, LogsEndpointParams, QueryType } from './Logs.types' import { convertResultsToCSV } from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils' @@ -66,43 +67,43 @@ const getDotKeys = (obj: { [k: string]: unknown }, parent?: string): string[] => }) } +// Fallback for filter keys with no template: a plain `key = value`. +// Drops the clause on non-scalar or un-encodable input. +const defaultResolveUnknownClause = (dotKey: string, value: unknown): SafeLogSqlFragment | null => { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + return null + } + try { + return safeSql`${quotedIdent(dotKey)} = ${analyticsLiteral(value)}` + } catch { + return null + } +} + /** - * Root keys in the filter object are considered to be AND filters. - * Nested keys under a root key are considered to be OR filters. + * Builds the AND-joined WHERE clauses for a table's filters. Top-level keys are + * ANDed, nested keys under one key are ORed. * - * For example: - * ``` - * {my_value: 'something', nested: {id: 123, test: 123 }} - * ``` - * This would be converted into `WHERE (my_value = 'something') and (id = 123 or test = 123) - * - * The template of the filter determines the actual filter statement. If no template is provided, a generic equality statement will be used. - * This only applies for root keys of the filter. - * For example: - * ``` - * {'my.nested.value': 123} - * ``` - * with no template, it will be converted into `WHERE (my.nested.value = 123) - * - * @returns a where statement with WHERE clause. + * `filterTemplates` and `resolveUnknownClause` can be passed in so the OTEL + * builders reuse this with their own ClickHouse conditions. Defaults keep the + * BigQuery behavior. */ -const buildWhereClauses = (table: LogsTableName, filters: Filters): SafeLogSqlFragment[] => { +export const buildWhereClauses = ( + table: LogsTableName, + filters: Filters, + filterTemplates: Record<string, SqlFilterEntry> = SQL_FILTER_TEMPLATES[table], + resolveUnknownClause: ( + dotKey: string, + value: unknown + ) => SafeLogSqlFragment | null = defaultResolveUnknownClause +): SafeLogSqlFragment[] => { const keys = Object.keys(filters) - const filterTemplates = SQL_FILTER_TEMPLATES[table] const _resolveTemplateToStatement = (dotKey: string): SafeLogSqlFragment | null => { const template = filterTemplates[dotKey] const value = get(filters, dotKey) if (template === undefined) { - // Unknown filter from a filter override — generic equality predicate; drop on bad input. - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - return null - } - try { - return safeSql`${quotedIdent(dotKey)} = ${analyticsLiteral(value)}` - } catch { - return null - } + return resolveUnknownClause(dotKey, value) } if (typeof template === 'function') { @@ -141,8 +142,13 @@ const buildWhereClauses = (table: LogsTableName, filters: Filters): SafeLogSqlFr .filter((s) => s !== null) } -const genWhereStatement = (table: LogsTableName, filters: Filters): SafeLogSqlFragment => { - const clauses = buildWhereClauses(table, filters) +export const genWhereStatement = ( + table: LogsTableName, + filters: Filters, + filterTemplates?: Record<string, SqlFilterEntry>, + resolveUnknownClause?: (dotKey: string, value: unknown) => SafeLogSqlFragment | null +): SafeLogSqlFragment => { + const clauses = buildWhereClauses(table, filters, filterTemplates, resolveUnknownClause) return clauses.length > 0 ? safeSql`where ${joinSqlFragments(clauses, ' and ')}` : safeSql`` } diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsErrorRenderers/DefaultErrorRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsErrorRenderers/DefaultErrorRenderer.tsx index 444cddf7969..ed9d6df9cf5 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsErrorRenderers/DefaultErrorRenderer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsErrorRenderers/DefaultErrorRenderer.tsx @@ -14,7 +14,7 @@ export const DefaultErrorRenderer: React.FC<ErrorRendererProps> = ({ error }) => language="json" hideLineNumbers value={typeof error === 'string' ? error : JSON.stringify(error, null, 2)} - className="w-full font-mono" + className="w-full font-mono px-4" /> </div> ) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx new file mode 100644 index 00000000000..1c52f2f5265 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx @@ -0,0 +1,34 @@ +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +interface LogsExplorerOtelBannerProps { + isRewriting: boolean + onRewrite: () => void + onDismiss: () => void +} + +export const LogsExplorerOtelBanner = ({ + isRewriting, + onRewrite, + onDismiss, +}: LogsExplorerOtelBannerProps) => { + return ( + <Admonition + type="default" + layout="horizontal" + className="mb-0 rounded-none border-x-0 border-t-0" + title="Logs now run on a ClickHouse-backed engine" + description="This query needs to be adjusted to ClickHouse SQL, which the Assistant can do for you." + actions={ + <div className="flex items-center gap-2"> + <Button variant="default" size="tiny" loading={isRewriting} onClick={onRewrite}> + Rewrite with Assistant + </Button> + <Button variant="text" size="tiny" onClick={onDismiss}> + Dismiss + </Button> + </div> + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 7afed9f5cc4..27e0f4c2660 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -1,11 +1,12 @@ -import { useFlag } from 'common' -import { BookOpen, Check, ChevronDown, ChevronsUpDown, Copy, ExternalLink, X } from 'lucide-react' +import { useFlag, useParams } from 'common' +import { BookOpen, Check, ChevronDown, ChevronsUpDown, Copy, ExternalLink } from 'lucide-react' import Link from 'next/link' import { ReactNode, useEffect, useState } from 'react' import { logConstants } from 'shared-data' import { Badge, Button, + Card, cn, Command, CommandEmpty, @@ -18,16 +19,27 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Label, Popover, PopoverContent, PopoverTrigger, - SidePanel, - Switch, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetSection, + SheetTitle, + SheetTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { EXPLORER_DATEPICKER_HELPERS, @@ -35,21 +47,23 @@ import { LogsTableName, } from './Logs.constants' import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers' +import { otelFieldsFromKeys, toOtelFieldSchemas } from './Logs.fieldReference' import { LogsWarning, LogTemplate } from './Logs.types' -import Table from '@/components/to-be-cleaned/Table' +import { useOtelLogKeysQuery } from '@/data/logs/otel-log-keys-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs' import { DOCS_URL } from '@/lib/constants' -export interface LogsQueryPanelProps { +interface LogsQueryPanelProps { templates?: LogTemplate[] value: DatePickerValue warnings: LogsWarning[] onSelectTemplate: (template: LogTemplate) => void onSelectSource: (source: string) => void onDateChange: (value: DatePickerValue) => void - useOtel?: boolean - onUseOtelChange?: (value: boolean) => void + showRewriteAction?: boolean + isRewriting?: boolean + onRewrite?: () => void } function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) { @@ -61,20 +75,19 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin ) } -const LogsQueryPanel = ({ +export const LogsQueryPanel = ({ templates = [], value, warnings, onSelectTemplate, onSelectSource, onDateChange, - useOtel = false, - onUseOtelChange, + showRewriteAction = false, + isRewriting = false, + onRewrite, }: LogsQueryPanelProps) => { const [showReference, setShowReference] = useState(false) const { logsTemplates } = useIsFeatureEnabled(['logs:templates']) - const showChToggleInLogExplorer = useFlag('showChToggleInLogExplorer') - const otelToggleEnabled = !!showChToggleInLogExplorer && !!onUseOtelChange const { projectAuthAll: authEnabled, @@ -101,11 +114,20 @@ const LogsQueryPanel = ({ const [open, setOpen] = useState(false) const showMultigresLogs = useShowMultigresLogs() - const schemas = logConstants.schemas.filter( + const useOtel = useFlag('otelLegacyLogs') + const baseSchemas = logConstants.schemas.filter( (schema) => schema.reference !== 'multigres_logs' || showMultigresLogs ) + const schemas = useOtel ? toOtelFieldSchemas(baseSchemas) : baseSchemas - const [selectedSchema, setSelectedSchema] = useState(schemas[0]) + const [selectedRef, setSelectedRef] = useState(schemas[0]?.reference) + const selectedSchema = schemas.find((s) => s.reference === selectedRef) ?? schemas[0] + + const { ref: projectRef } = useParams() + const { data: discoveredKeys, isPending: isLoadingKeys } = useOtelLogKeysQuery( + { projectRef, source: selectedRef }, + { enabled: useOtel && showReference } + ) return ( <div className="flex items-center border-b bg-surface-100 h-(--header-height)"> @@ -167,30 +189,6 @@ const LogsQueryPanel = ({ helpers={EXPLORER_DATEPICKER_HELPERS} /> - {otelToggleEnabled && ( - <Tooltip> - <TooltipTrigger asChild> - <div className="flex items-center gap-2"> - <Switch - id="logs-explorer-otel-toggle" - checked={useOtel} - onCheckedChange={(checked) => onUseOtelChange?.(checked)} - /> - <Label - htmlFor="logs-explorer-otel-toggle" - className="text-xs text-foreground-light cursor-pointer" - > - OTEL endpoint - </Label> - </div> - </TooltipTrigger> - <TooltipContent side="bottom" className="max-w-xs"> - Run this query against the new ClickHouse-backed OTEL endpoint instead of - BigQuery. Use to validate ClickHouse SQL before relying on it. - </TooltipContent> - </Tooltip> - )} - <div data-testid="log-explorer-warnings" className={`transition-all duration-300 h-full ${ @@ -220,116 +218,142 @@ const LogsQueryPanel = ({ </div> </div> - <SidePanel - size="large" - header={ - <div className="flex flex-row justify-between items-center"> - <h3>Field Reference</h3> - <Button - variant="text" - className="px-1" - onClick={() => setShowReference(false)} - icon={<X />} - /> - </div> - } - visible={showReference} - cancelText="Close" - onCancel={() => setShowReference(false)} - hideFooter - triggerElement={ - <Button - variant="text" - onClick={() => setShowReference(true)} - icon={<BookOpen />} - className="px-2" - > - <span>Field Reference</span> - </Button> - } - > - <SidePanel.Content> - <div className="pt-4 pb-2 space-y-1"> - <p className="text-sm"> - The following table shows all the available paths that can be queried from each - respective source. Do note that to access nested keys, you would need to perform - the necessary{' '} - <Link - href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`} - target="_blank" - rel="noreferrer" - className="text-brand" - > - unnesting joins - <ExternalLink - size="14" - className="ml-1 inline translate-y-[-2px]" - strokeWidth={1.5} - /> - </Link> - </p> - </div> - </SidePanel.Content> - <SidePanel.Separator /> - - <div className="px-4 pb-4 flex flex-col gap-4"> - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> + <div className="flex items-center gap-1"> + {showRewriteAction && onRewrite && ( + <Tooltip> + <TooltipTrigger asChild> <Button - variant="default" - role="combobox" - size={'small'} - aria-expanded={open} - className="w-full justify-between" - iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />} + variant="outline" + loading={isRewriting} + onClick={onRewrite} + className="px-2" > - {value ? selectedSchema?.name : 'Select source...'} + Rewrite query to ClickHouse SQL </Button> - </PopoverTrigger> - <PopoverContent className="p-0" sameWidthAsTrigger> - <Command> - <CommandInput placeholder="Search source..." /> - <CommandList> - <CommandEmpty>No source found.</CommandEmpty> - <CommandGroup> - {schemas.map((schema) => ( - <CommandItem - key={schema.reference} - value={schema.reference} - onSelect={() => { - setSelectedSchema(schema) - setOpen(false) - }} - > - <Check - className={cn( - 'mr-2 h-4 w-4', - selectedSchema === schema ? 'opacity-100' : 'opacity-0' - )} - /> - {schema.name} - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <Table - head={[ - <Table.th className="text-xs p-2!" key="path"> - Path - </Table.th>, - <Table.th key="type" className="text-xs p-2!"> - Type - </Table.th>, - ]} - body={selectedSchema.fields.map((field) => ( - <Field key={field.path} field={field} /> - ))} - /> - </div> - </SidePanel> + </TooltipTrigger> + <TooltipContent side="bottom" className="w-72 text-center"> + Logs now run on a ClickHouse-backed engine. Click to rewrite this query with the + Assistant. + </TooltipContent> + </Tooltip> + )} + + <Sheet open={showReference} onOpenChange={setShowReference}> + <SheetTrigger asChild> + <Button variant="text" icon={<BookOpen />} className="px-2"> + <span>Field Reference</span> + </Button> + </SheetTrigger> + <SheetContent size="lg" className="flex w-full flex-col gap-0 p-0"> + <SheetHeader> + <SheetTitle>Field Reference</SheetTitle> + {useOtel ? ( + <SheetDescription> + The following table shows the fields available on each source. Nested fields + live in the{' '} + <code className="text-code-inline text-xs !break-keep">log_attributes</code>{' '} + map and are read with{' '} + <code className="text-code-inline text-xs !break-keep"> + log_attributes['key'] + </code>{' '} + — no unnesting joins needed. + </SheetDescription> + ) : ( + <SheetDescription> + The following table shows all the available paths that can be queried from + each respective source. Do note that to access nested keys, you would need to + perform the necessary{' '} + <Link + href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`} + target="_blank" + rel="noreferrer" + className="text-brand" + > + unnesting joins + <ExternalLink + size="14" + className="ml-1 inline translate-y-[-2px]" + strokeWidth={1.5} + /> + </Link> + </SheetDescription> + )} + </SheetHeader> + + <SheetSection className="flex flex-col gap-y-4 overflow-y-auto "> + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="default" + role="combobox" + size={'small'} + aria-expanded={open} + className="w-full justify-between" + iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />} + > + {value ? selectedSchema?.name : 'Select source...'} + </Button> + </PopoverTrigger> + <PopoverContent className="p-0" sameWidthAsTrigger> + <Command> + <CommandInput placeholder="Search source..." /> + <CommandList> + <CommandEmpty>No source found.</CommandEmpty> + <CommandGroup> + {schemas.map((schema) => ( + <CommandItem + key={schema.reference} + value={schema.reference} + onSelect={() => { + setSelectedRef(schema.reference) + setOpen(false) + }} + > + <Check + className={cn( + 'mr-2 h-4 w-4', + selectedSchema?.reference === schema.reference + ? 'opacity-100' + : 'opacity-0' + )} + /> + {schema.name} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + <Card className="overflow-y-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="text-xs p-2!">Path</TableHead> + <TableHead className="text-xs p-2!">Type</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {useOtel && isLoadingKeys + ? Array.from({ length: 3 }).map((_, i) => ( + <TableRow key={i}> + <TableCell colSpan={2} className="p-2"> + <ShimmeringLoader /> + </TableCell> + </TableRow> + )) + : (useOtel + ? otelFieldsFromKeys(discoveredKeys ?? []) + : selectedSchema.fields + ).map((field) => <Field key={field.path} field={field} />)} + </TableBody> + </Table> + </Card> + </SheetSection> + </SheetContent> + </Sheet> + </div> </div> </div> </div> @@ -347,8 +371,8 @@ const Field = ({ const [isCopied, setIsCopied] = useState(false) return ( - <Table.tr> - <Table.td + <TableRow> + <TableCell className="font-mono text-xs p-2! cursor-pointer hover:text-foreground transition flex items-center space-x-2" onClick={() => copyToClipboard(field.path, () => { @@ -373,10 +397,8 @@ const Field = ({ <TooltipContent side="bottom">Copy value</TooltipContent> </Tooltip> )} - </Table.td> - <Table.td className="font-mono text-xs p-2!">{field.type}</Table.td> - </Table.tr> + </TableCell> + <TableCell className="font-mono text-xs p-2!">{field.type}</TableCell> + </TableRow> ) } - -export default LogsQueryPanel diff --git a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx deleted file mode 100644 index 7fdc180da73..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx +++ /dev/null @@ -1,352 +0,0 @@ -import { useParams } from 'common' -import dayjs from 'dayjs' -import { Eye, EyeOff, RefreshCw, Terminal } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' -import { Button, Calendar, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import type { - CustomOptionProps, - FilterCondition, - FilterGroup, - FilterProperty, -} from 'ui-patterns/FilterBar' -import { FilterBar } from 'ui-patterns/FilterBar' - -import { FILTER_OPTIONS, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './Logs.constants' -import { DatePickerValue } from './Logs.DatePickers' -import type { Filters, LogSearchCallback, LogTemplate } from './Logs.types' -import { DatabaseSelector } from '@/components/ui/DatabaseSelector' -import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query' -import { IS_PLATFORM } from '@/lib/constants' - -function isBooleanMap(v: unknown): v is Record<string, boolean> { - return typeof v === 'object' && v !== null && !Array.isArray(v) -} - -function CustomDateRangePicker({ onChange, onCancel }: CustomOptionProps) { - const [dateRange, setDateRange] = useState<any | undefined>() - - return ( - <div className="w-full space-y-4"> - <Calendar - initialFocus - mode="range" - defaultMonth={dateRange?.from} - selected={dateRange} - onSelect={setDateRange} - numberOfMonths={2} - /> - <div className="flex justify-end gap-2 py-3 px-4 border-t"> - <Button variant="default" onClick={onCancel}> - Cancel - </Button> - <Button - variant="primary" - onClick={() => { - if (dateRange?.from) { - const from = dayjs(dateRange.from).toISOString() - const to = dateRange.to ? dayjs(dateRange.to).toISOString() : dayjs().toISOString() - onChange(`${from}|${to}`) - } - }} - > - Apply - </Button> - </div> - </div> - ) -} - -interface PreviewFilterPanelProps { - defaultSearchValue?: string - defaultToValue?: string - defaultFromValue?: string - templates?: any - isLoading: boolean - newCount: number - onRefresh?: () => void - onSearch?: LogSearchCallback - onExploreClick?: () => void - queryUrl: string - onSelectTemplate: (template: LogTemplate) => void - table: LogsTableName - condensedLayout: Boolean - isShowingEventChart: boolean - onToggleEventChart: () => void - csvData?: unknown[] - onFiltersChange: (filters: Filters) => void - filters: Filters - onSelectedDatabaseChange: (id: string) => void - className?: string - selectedDatePickerValue: DatePickerValue - setSelectedDatePickerValue: (value: DatePickerValue) => void -} - -export const PreviewFilterPanelWithUniversal = ({ - isLoading, - newCount, - onRefresh, - onSearch = () => {}, - defaultSearchValue = '', - defaultFromValue, - defaultToValue, - onExploreClick, - queryUrl, - condensedLayout, - isShowingEventChart, - onToggleEventChart, - csvData: _csvData, - onFiltersChange, - filters, - table, - onSelectedDatabaseChange, - className, - selectedDatePickerValue: _selectedDatePickerValue, - setSelectedDatePickerValue: _setSelectedDatePickerValue, -}: PreviewFilterPanelProps) => { - const router = useRouter() - const { ref } = useParams() - - const { data: loadBalancers } = useLoadBalancersQuery({ projectRef: ref }) - - const showDatabaseSelector = - IS_PLATFORM && LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname) - - const filterProperties = useMemo(() => { - const tableFilters = FILTER_OPTIONS[table] - if (!tableFilters) return [] - - const properties: FilterProperty[] = [] - - // Add date range filter - properties.push({ - label: 'Time Range', - name: 'timerange', - type: 'string' as const, - operators: ['='], - options: [ - { - label: 'Last 15 minutes', - value: `${dayjs().subtract(15, 'minute').toISOString()}|${dayjs().toISOString()}`, - }, - { - label: 'Last 30 minutes', - value: `${dayjs().subtract(30, 'minute').toISOString()}|${dayjs().toISOString()}`, - }, - { - label: 'Last hour', - value: `${dayjs().subtract(1, 'hour').toISOString()}|${dayjs().toISOString()}`, - }, - { - label: 'Last 3 hours', - value: `${dayjs().subtract(3, 'hour').toISOString()}|${dayjs().toISOString()}`, - }, - { - label: 'Last 24 hours', - value: `${dayjs().subtract(24, 'hour').toISOString()}|${dayjs().toISOString()}`, - }, - { - label: 'Custom Range...', - component: (props: CustomOptionProps) => <CustomDateRangePicker {...props} />, - }, - ], - }) - - // Add table-specific filters - Object.entries(tableFilters).forEach(([_key, filterSet]) => { - properties.push({ - label: filterSet.label, - name: filterSet.key, - type: 'string' as const, - operators: ['='], - options: filterSet.options.map((option) => ({ - label: option.label, - value: option.key, - description: option.description, - })), - }) - }) - - return properties - }, [table]) - - // Convert Logs.Filters to FilterBar.FilterGroup - const filterBarFilters = useMemo(() => { - const conditions: FilterCondition[] = [] - - // Handle date range - only add from defaultValues if no timerange filter exists - if ((defaultFromValue || defaultToValue) && !filters.timerange) { - conditions.push({ - propertyName: 'timerange', - operator: '=', - value: `${defaultFromValue || ''}|${defaultToValue || ''}`, - }) - } - - // Convert other filters - Object.entries(filters).forEach(([key, value]) => { - if (key === 'search_query') return // Skip search_query as it's handled in freeform text - - if (typeof value === 'object' && value !== null) { - // If the filter object is empty, add a condition with no value - if (Object.keys(value).length === 0) { - conditions.push({ - propertyName: key, - operator: '=', - value: '', - }) - return - } - - // Handle active filters - Object.entries(value as Record<string, boolean>).forEach(([subKey, isEnabled]) => { - if (isEnabled) { - conditions.push({ - propertyName: key, - operator: '=', - value: subKey, - }) - } - }) - } else if (value !== undefined && value !== null) { - conditions.push({ - propertyName: key, - operator: '=', - value: String(value), - }) - } - }) - - return { - logicalOperator: 'AND', - conditions, - } as FilterGroup - }, [filters, defaultFromValue, defaultToValue]) - - const handleFilterChange = (filterGroup: FilterGroup) => { - const newFilters: Filters = {} - let hasTimeRange = false - - filterGroup.conditions.forEach((condition) => { - if (!('propertyName' in condition)) return - - const propertyName = condition.propertyName - if (!propertyName) return - - if (propertyName === 'timerange') { - hasTimeRange = true - if (condition.value) { - const value = String(condition.value) - const [from, to] = value.split('|') - newFilters.timerange = { [value]: true } - onSearch('datepicker-change', { from, to }) - } else { - newFilters.timerange = {} - onSearch('datepicker-change', { from: '', to: '' }) - } - } else { - if (!newFilters[propertyName]) { - newFilters[propertyName] = {} - } - - if (!condition.value) { - return - } - - if (typeof condition.value === 'string') { - const current = newFilters[propertyName] - const next = isBooleanMap(current) ? { ...current } : {} - next[condition.value] = true - newFilters[propertyName] = next - } - } - }) - - // If timerange was completely removed from the filter group - if (!hasTimeRange) { - onSearch('datepicker-change', { from: '', to: '' }) - } - - onFiltersChange(newFilters) - } - - return ( - <div className={cn('flex w-full flex-col gap-2', condensedLayout ? ' p-3' : '', className)}> - <div className="flex items-center justify-between gap-x-2"> - <div className="flex-1"> - <FilterBar - filterProperties={filterProperties} - filters={filterBarFilters} - onFilterChange={handleFilterChange} - freeformText={defaultSearchValue} - onFreeformTextChange={(value) => onSearch('search-input-change', { query: value })} - /> - </div> - - <div className="flex items-center gap-x-2"> - <Button - title="refresh" - variant="default" - className="px-1.5" - icon={ - <div className="relative"> - {newCount > 0 && ( - <div className="absolute -top-3 -right-3 flex items-center justify-center"> - <div className="absolute h-4 w-4 animate-ping rounded-full bg-brand opacity-60"></div> - <div className="relative z-10 flex h-4 min-w-4 items-center justify-center rounded-full bg-brand-500 px-1 text-[10px] font-medium leading-none text-white"> - {newCount > 1000 ? `${Math.floor(newCount / 100) / 10}K` : newCount} - </div> - </div> - )} - <RefreshCw /> - </div> - } - loading={isLoading} - disabled={isLoading} - onClick={onRefresh} - /> - - <Button - variant="default" - onClick={() => onToggleEventChart()} - icon={isShowingEventChart ? <Eye /> : <EyeOff />} - > - Chart - </Button> - - {/* <CSVButton data={csvData} disabled={!Boolean(csvData)} title="Download data" /> */} - - {showDatabaseSelector ? ( - <> - <Tooltip> - <TooltipTrigger asChild> - <Button asChild className="px-1.5" variant="default" icon={<Terminal />}> - <Link href={queryUrl} /> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom" className="text-xs"> - Open query in Logs Explorer - </TooltipContent> - </Tooltip> - <DatabaseSelector - onSelectId={onSelectedDatabaseChange} - additionalOptions={ - table === LogsTableName.EDGE - ? (loadBalancers ?? []).length > 0 - ? [{ id: `${ref}-all`, name: 'API Load Balancer' }] - : [] - : [] - } - /> - </> - ) : ( - <Button asChild variant="default" onClick={onExploreClick}> - <Link href={queryUrl}>Explore via query</Link> - </Button> - )} - </div> - </div> - </div> - ) -} diff --git a/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx b/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx index 02518afc7f4..d0fdb2505ed 100644 --- a/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx @@ -23,7 +23,7 @@ const RecentQueriesItem: React.FC<Props> = ({ item }) => { </Table.td> <Table.td className="text-right"> <Button - variant="alternative" + variant="primary" iconRight={<Play size={10} />} onClick={() => router.push(`/project/${ref}/logs/explorer?q=${encodeURIComponent(item.sql)}`) diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts b/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts new file mode 100644 index 00000000000..1b4f0646410 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + buildClickhouseRewritePrompt, + detectLogSource, + looksLikeLegacyLogsQuery, + rewriteLogsSqlWithAI, + stripSqlCodeFences, +} from './logs-sql-rewrite' + +describe('buildClickhouseRewritePrompt', () => { + it('includes the query, the schema, and a reply-with-only-SQL instruction', () => { + const prompt = buildClickhouseRewritePrompt('select count(*) from edge_logs') + expect(prompt).toContain('select count(*) from edge_logs') + expect(prompt).toContain('log_attributes') + expect(prompt).toContain("source = 'edge_logs'") + expect(prompt.toLowerCase()).toContain('reply with only') + }) + + it('spells out the FROM-to-logs conversion and shows a worked example', () => { + const prompt = buildClickhouseRewritePrompt('select 1 from postgres_logs') + expect(prompt).toContain("from logs where source = 'postgres_logs'") + expect(prompt.toLowerCase()).toContain('remove every') + expect(prompt).toContain('cross join unnest') + expect(prompt).toContain('BigQuery:') + expect(prompt).toContain('ClickHouse:') + expect(prompt).toContain("log_attributes['parsed.error_severity']") + }) + + it('lists the real log_attributes keys when provided and demands exact paths', () => { + const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs', [ + 'request.headers.x_real_ip', + 'request.cf.country', + ]) + expect(prompt).toContain("log_attributes['request.headers.x_real_ip']") + expect(prompt).toContain("log_attributes['request.cf.country']") + expect(prompt.toLowerCase()).toContain('exact') + }) + + it('omits the keys section when none are provided', () => { + const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs') + expect(prompt).not.toContain('actual log_attributes keys present') + }) +}) + +describe('detectLogSource', () => { + it('reads an explicit source filter', () => { + expect(detectLogSource("select 1 from logs where source = 'edge_logs'")).toBe('edge_logs') + }) + + it('falls back to the legacy FROM table name', () => { + expect(detectLogSource('select 1 from edge_logs as t')).toBe('edge_logs') + }) + + it('maps pg_cron_logs to postgres_logs from either the FROM table or the source filter', () => { + expect(detectLogSource('select 1 from pg_cron_logs')).toBe('postgres_logs') + expect(detectLogSource("select 1 from logs where source = 'pg_cron_logs'")).toBe( + 'postgres_logs' + ) + }) + + it('returns undefined for the bare logs table with no source', () => { + expect(detectLogSource('select 1 from logs limit 5')).toBeUndefined() + }) + + it('returns undefined when nothing matches', () => { + expect(detectLogSource('select 1')).toBeUndefined() + }) +}) + +describe('looksLikeLegacyLogsQuery', () => { + it('flags per-service FROM tables', () => { + expect(looksLikeLegacyLogsQuery('select 1 from edge_logs')).toBe(true) + }) + + it('flags unnest joins and the cast-timestamp idiom', () => { + expect(looksLikeLegacyLogsQuery('select 1 from logs cross join unnest(metadata) as m')).toBe( + true + ) + expect(looksLikeLegacyLogsQuery('select cast(timestamp as datetime) from logs')).toBe(true) + }) + + it('does not flag a ClickHouse query against the logs table', () => { + expect( + looksLikeLegacyLogsQuery("select timestamp from logs where source = 'edge_logs' limit 5") + ).toBe(false) + }) +}) + +describe('stripSqlCodeFences', () => { + it('removes a ```sql fenced block', () => { + expect(stripSqlCodeFences('```sql\nselect 1 from logs\n```')).toBe('select 1 from logs') + }) + + it('removes a plain ``` fenced block', () => { + expect(stripSqlCodeFences('```\nselect 1\n```')).toBe('select 1') + }) + + it('leaves unfenced SQL untouched (trimmed)', () => { + expect(stripSqlCodeFences(' select 1 from logs ')).toBe('select 1 from logs') + }) + + it('extracts the fenced block when wrapped in prose', () => { + expect(stripSqlCodeFences('Here is the rewrite:\n```sql\nselect 1 from logs\n```')).toBe( + 'select 1 from logs' + ) + }) +}) + +describe('rewriteLogsSqlWithAI', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posts to the completion endpoint and returns the cleaned query', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => '```sql\nselect 1 from logs\n```', + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await rewriteLogsSqlWithAI({ + sql: 'select 1 from edge_logs', + projectRef: 'abc', + }) + + expect(result).toBe('select 1 from logs') + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('/api/ai/code/complete') + const body = JSON.parse(init.body) + expect(body.dialect).toBe('clickhouse') + expect(body.completionMetadata.selection).toBe('select 1 from edge_logs') + expect(body.completionMetadata.prompt.toLowerCase()).toContain('reply with only') + }) + + it('throws when the request fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => 'boom' })) + await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow( + 'boom' + ) + }) + + it('throws when the model returns an empty query', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ' ' })) + await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow( + 'empty' + ) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts b/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts new file mode 100644 index 00000000000..ca532f204a4 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts @@ -0,0 +1,142 @@ +import { BASE_PATH } from '@/lib/constants' + +export const LOGS_SCHEMA_REFERENCE = `The logs table (ClickHouse) has these columns: +- id (String) +- timestamp (DateTime64, UTC) formatted like 2026-06-22T09:34:06.215000 (ISO 8601, microsecond precision, no trailing Z) +- event_message (String): the raw log line +- severity_text (String): log level when present +- source (String): the service the log belongs to. Always filter by it, e.g. where source = 'edge_logs'. +- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key']. Values are strings, so wrap numeric ones in toInt32OrZero(...) for comparisons. + +Sources and their common log_attributes keys: +- edge_logs: request.method, request.path, request.search, response.status_code, identifier +- postgres_logs: parsed.error_severity, parsed.detail, parsed.hint, parsed.query, identifier +- pg_cron logs live under source = 'postgres_logs' (parsed.error_severity, parsed.query) +- auth_logs: level, status, path, msg, error +- function_edge_logs: response.status_code, request.method, request.pathname, function_id, execution_id, execution_time_ms +- function_logs: event_type, function_id, execution_id, level +- storage_logs, realtime_logs, postgrest_logs, supavisor_logs, pgbouncer_logs: mostly id, timestamp, event_message, with extra fields in log_attributes + +Rules: always filter by source; the editor applies the selected time range so a timestamp filter is usually unnecessary; the old BigQuery unnest joins become log_attributes['key'] lookups (drop the metadata root).` + +function renderAvailableKeys(availableKeys?: string[]): string { + if (!availableKeys || availableKeys.length === 0) return '' + const list = availableKeys.map((key) => `- log_attributes['${key}']`).join('\n') + return `\nThe actual log_attributes keys present for this source are listed below. Use these EXACT keys — do not invent, shorten, or drop any dotted prefix. If a BigQuery field maps to one of these (e.g. request.headers.x_real_ip, request.cf.country), use the full key shown here: +${list}\n` +} + +export function buildClickhouseRewritePrompt(sql: string, availableKeys?: string[]): string { + return `${LOGS_SCHEMA_REFERENCE} +${renderAvailableKeys(availableKeys)} +Convert the BigQuery logs query below to ClickHouse SQL for the logs table. There are no per-service tables and no unnest joins in ClickHouse. Follow these rules exactly: + +1. Replace the FROM table with the single logs table and filter by source. The old table name is the source value: "from postgres_logs as t" becomes "from logs where source = 'postgres_logs'". This is required, never select from a table like postgres_logs or edge_logs. +2. Remove every join that unnests metadata or its structs. This includes "cross join unnest(...)" and "left join unnest(...) on true". +3. Replace any column that came from an unnest alias with a log_attributes lookup. A field off unnest(metadata) becomes log_attributes['field']; a field off a nested struct like unnest(m.parsed) becomes log_attributes['parsed.field'] (keep the struct name as a dotted prefix, drop the metadata root and every alias). When the actual keys are listed above, match against them and use the full dotted key exactly. +4. Wrap numeric fields in toInt32OrZero(...) before comparing or aggregating them. +5. Replace BigQuery functions with ClickHouse equivalents: regexp_contains(x, 'p') becomes match(x, 'p'), or x ILIKE '%p%' for a plain substring. Replace cast(timestamp as datetime) with timestamp. Use count() instead of count(*). +6. Preserve the original select list, filters, group by, order by, and limit intent. + +Example. +BigQuery: +select count(t.timestamp) as count, p.error_severity +from postgres_logs as t +cross join unnest(metadata) as m +cross join unnest(m.parsed) as p +where p.error_severity in ('ERROR', 'FATAL', 'PANIC') +group by p.error_severity +order by count desc +limit 100 + +ClickHouse: +select count() as count, log_attributes['parsed.error_severity'] as error_severity +from logs +where source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') +group by log_attributes['parsed.error_severity'] +order by count desc +limit 100 + +Reply with ONLY the rewritten SQL query: no explanation, no comments, and no markdown code fences. + +${sql}` +} + +export function stripSqlCodeFences(text: string): string { + const trimmed = text.trim() + const fenced = trimmed.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/i) + return (fenced ? fenced[1] : trimmed).trim() +} + +const SOURCE_ALIASES: Record<string, string> = { + pg_cron_logs: 'postgres_logs', +} + +export function detectLogSource(sql: string): string | undefined { + const bySource = sql.match(/source\s*=\s*'([^']+)'/i) + if (bySource) { + const source = bySource[1].toLowerCase() + return SOURCE_ALIASES[source] ?? source + } + const byFrom = sql.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i) + if (byFrom) { + const table = byFrom[1].toLowerCase() + if (table === 'logs') return undefined + return SOURCE_ALIASES[table] ?? table + } + return undefined +} + +export function looksLikeLegacyLogsQuery(sql: string): boolean { + const lower = sql.toLowerCase() + if (/\bunnest\s*\(/.test(lower)) return true + if (/cast\s*\(\s*timestamp\s+as\s+datetime\s*\)/.test(lower)) return true + const byFrom = lower.match(/\bfrom\s+([a-z_][a-z0-9_]*)/) + return byFrom ? byFrom[1] !== 'logs' : false +} + +export interface RewriteLogsSqlArgs { + sql: string + projectRef: string + connectionString?: string | null + orgSlug?: string + authorizationHeader?: string | null + availableKeys?: string[] +} + +export async function rewriteLogsSqlWithAI(args: RewriteLogsSqlArgs) { + const { sql, projectRef, connectionString, orgSlug, authorizationHeader, availableKeys } = args + + const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(authorizationHeader ? { Authorization: authorizationHeader } : {}), + }, + body: JSON.stringify({ + projectRef, + connectionString, + language: 'sql', + dialect: 'clickhouse', + orgSlug, + completionMetadata: { + textBeforeCursor: '', + textAfterCursor: '', + language: 'pgsql', + prompt: buildClickhouseRewritePrompt(sql, availableKeys), + selection: sql, + }, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || 'Failed to rewrite the query') + } + + const raw = await response.json() + const rewritten = stripSqlCodeFences(typeof raw === 'string' ? raw : String(raw)) + if (!rewritten) throw new Error('The assistant returned an empty query') + return rewritten +} diff --git a/apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx b/apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx index 3793449b1bf..f86c6c13bed 100644 --- a/apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx +++ b/apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx @@ -5,7 +5,7 @@ import { useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Form, FormControl, FormField, Input } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' @@ -76,6 +76,7 @@ const ConfirmResetCodeForm = ({ email }: { email: string }) => { <Form {...codeForm}> <form id="code-input-form" + method="POST" className="flex flex-col pt-4 space-y-4" onSubmit={codeForm.handleSubmit(onCodeEntered)} > @@ -148,6 +149,7 @@ const ForgotPasswordForm = ({ onSuccess }: { onSuccess: (email: string) => void <Form {...forgotPasswordForm}> <form id="forgot-password-form" + method="POST" className="flex flex-col pt-4 space-y-4" onSubmit={forgotPasswordForm.handleSubmit(onForgotPassword)} > diff --git a/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx b/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx index e928bcbc81c..09cb69ccf4b 100644 --- a/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx +++ b/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx @@ -76,7 +76,7 @@ export const ResetPasswordForm = () => { return ( <Form {...form}> - <form onSubmit={form.handleSubmit(onResetPassword)} className="space-y-4 pt-4"> + <form method="POST" onSubmit={form.handleSubmit(onResetPassword)} className="space-y-4 pt-4"> {requireCurrentPassword && ( <FormField control={form.control} diff --git a/apps/studio/components/interfaces/SignIn/SignInForm.tsx b/apps/studio/components/interfaces/SignIn/SignInForm.tsx index db91be3317c..755d30a08d1 100644 --- a/apps/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInForm.tsx @@ -14,7 +14,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { LastSignInWrapper } from './LastSignInWrapper' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useAddLoginEvent } from '@/data/misc/audit-login-mutation' import { getMfaAuthenticatorAssuranceLevel } from '@/data/profile/mfa-authenticator-assurance-level-query' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' @@ -121,7 +121,12 @@ export const SignInForm = () => { return ( <Form {...form}> - <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}> + <form + id={formId} + method="POST" + className="flex flex-col gap-4" + onSubmit={form.handleSubmit(onSubmit)} + > {authError && <AlertError error={authError} subject="Error while signing in" />} <FormField key="email" diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index 3ac88449fcc..88198f6808d 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -14,7 +14,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import z from 'zod' import { SupportLink } from '../Support/SupportLink' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useMfaChallengeAndVerifyMutation } from '@/data/profile/mfa-challenge-and-verify-mutation' import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query' import { useSignOut } from '@/lib/auth' @@ -128,7 +128,12 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { {isSuccessFactors && ( <Form {...form}> - <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}> + <form + id={formId} + method="POST" + className="flex flex-col gap-4" + onSubmit={form.handleSubmit(onSubmit)} + > <FormField key="code" name="code" diff --git a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx index 5912d08543a..e27443d1363 100644 --- a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx @@ -73,7 +73,12 @@ export const SignInSSOForm = () => { return ( <Form {...form}> - <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}> + <form + id={formId} + method="POST" + className="flex flex-col gap-4" + onSubmit={form.handleSubmit(onSubmit)} + > <FormField key="email" name="email" diff --git a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx index f1bcacd97aa..14cbcbf82c4 100644 --- a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx @@ -25,6 +25,8 @@ import PasswordConditionsHelper from './PasswordConditionsHelper' import { useSignUpMutation } from '@/data/misc/signup-mutation' import { BASE_PATH } from '@/lib/constants' import { buildPathWithParams } from '@/lib/gotrue' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' const schema = z.object({ email: z.string().min(1, 'Email is required').email('Must be a valid email'), @@ -67,6 +69,8 @@ export const SignUpForm = () => { token: parseAsString.withDefault(''), }) + const trackFunnelError = useTrackFunnelError() + const { mutate: signup, isPending: isSigningUp } = useSignUpMutation({ onSuccess: () => { toast.success(`Signed up successfully!`) @@ -76,6 +80,7 @@ export const SignUpForm = () => { setCaptchaToken(null) captchaRef.current?.resetCaptcha() toast.error(`Failed to sign up: ${error.message}`) + trackFunnelError('signup', classifyApiError('signup', error), 'toast') }, }) @@ -144,7 +149,14 @@ export const SignUpForm = () => { )} > <Form {...form}> - <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}> + <form + id={formId} + method="POST" + className="flex flex-col gap-4" + onSubmit={form.handleSubmit(onSubmit, (errors) => + trackFunnelError('signup', classifyValidationError('signup', errors), 'form') + )} + > <FormField key="email" name="email" diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts index fe4274afc4d..cb103ee81de 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts @@ -19,7 +19,7 @@ export const LABELS: Record<string, string> = { export const DESCRIPTIONS: Record<string, string> = { vault_aws_access_key_id: 'Matches the AWS access key ID from an S3 access key.', vault_aws_secret_access_key: 'Matches the AWS secret access from an S3 access key.', - vault_token: 'Corresponds to the service role key.', + vault_token: 'Corresponds to the secret or service role key.', warehouse: 'Matches the name of this bucket.', 's3.endpoint': '', catalog_uri: '', diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx new file mode 100644 index 00000000000..3876dbf344b --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx @@ -0,0 +1,136 @@ +import Link from 'next/link' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { SimpleConfigurationDetails } from './SimpleConfigurationDetails' +import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' +import { ScaffoldSection } from '@/components/layouts/Scaffold' +import { InlineLink } from '@/components/ui/InlineLink' +import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query' +import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation' +import { DOCS_URL } from '@/lib/constants' +import { isLessThan } from '@/lib/semver' + +export const ExtensionNotInstalled = ({ + bucketName, + projectRef, + wrapperMeta, + wrappersExtension, +}: { + bucketName?: string + projectRef: string + wrapperMeta: WrapperMeta + wrappersExtension: DatabaseExtension +}) => { + const databaseNeedsUpgrading = isLessThan( + wrappersExtension?.default_version ?? '', + wrapperMeta?.minimumExtensionVersion ?? '' + ) + + return ( + <> + <ScaffoldSection isFullWidth> + <Admonition type="warning" title="Missing required extension"> + <p> + The Wrappers extension is required in order to query analytics tables.{' '} + {databaseNeedsUpgrading && + 'Please first upgrade your database and then install the extension.'}{' '} + <InlineLink + href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`} + target="_blank" + rel="noreferrer" + className="text-foreground-lighter hover:text-foreground transition-colors" + > + Learn more + </InlineLink> + </p> + <Button variant="default" asChild className="mt-2" onClick={() => {}}> + <Link + href={ + databaseNeedsUpgrading + ? `/project/${projectRef}/settings/infrastructure` + : `/project/${projectRef}/database/extensions?filter=wrappers` + } + > + {databaseNeedsUpgrading ? 'Upgrade database' : 'Install extension'} + </Link> + </Button> + </Admonition> + </ScaffoldSection> + <SimpleConfigurationDetails bucketName={bucketName} /> + </> + ) +} + +export const ExtensionNeedsUpgrade = ({ + bucketName, + projectRef, + wrapperMeta, + wrappersExtension, +}: { + bucketName?: string + projectRef: string + wrapperMeta: WrapperMeta + wrappersExtension: DatabaseExtension +}) => { + // [Joshen] Default version is what's on the DB, so if the installed version is already the default version + // but still doesnt meet the minimum extension version, then DB upgrade is required + const databaseNeedsUpgrading = + wrappersExtension?.installed_version === wrappersExtension?.default_version + + return ( + <> + <ScaffoldSection isFullWidth> + <Admonition type="warning" title="Outdated extension version"> + <p> + The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} + {wrapperMeta.minimumExtensionVersion}. You have version{' '} + {wrappersExtension?.installed_version} installed. Please{' '} + {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension + by disabling and enabling the Wrappers extension. + </p> + <p> + Before reinstalling the wrapper extension, you must first remove all existing wrappers. + Afterward, you can recreate the wrappers. + </p> + <Button asChild variant="default"> + <Link + href={ + databaseNeedsUpgrading + ? `/project/${projectRef}/settings/infrastructure` + : `/project/${projectRef}/database/extensions?filter=wrappers` + } + > + {databaseNeedsUpgrading ? 'Upgrade database' : 'Extensions'} + </Link> + </Button> + </Admonition> + </ScaffoldSection> + <SimpleConfigurationDetails bucketName={bucketName} /> + </> + ) +} + +export const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { + const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } = + useIcebergWrapperCreateMutation() + + const onSetupWrapper = async () => { + if (!bucketName) return console.error('Bucket name is required') + await createIcebergWrapper({ bucketName }) + } + + return ( + <> + <ScaffoldSection isFullWidth> + <Admonition type="warning" title="Missing integration"> + <p>The Iceberg Wrapper integration is required in order to query analytics tables.</p> + <Button variant="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}> + Install wrapper + </Button> + </Admonition> + </ScaffoldSection> + <SimpleConfigurationDetails bucketName={bucketName} /> + </> + ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx index e96f0ae1688..a4e936a0d5d 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx @@ -345,7 +345,7 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen href={`/project/${projectRef}/database/replication/${pipeline?.id}?search=${inferredPostgresTable.schema}.${inferredPostgresTable.name}`} > <Eye size={12} className="text-foreground-lighter" /> - <p>View replication</p> + <p>View pipeline</p> </Link> </DropdownMenuItem> )} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx index 386def2c402..6000042a6bf 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx @@ -1,8 +1,11 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Card } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './AnalyticsBucketDetails.constants' import { CopyEnvButton } from './CopyEnvButton' import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput' +import { UpdateCatalogTokenDialog } from './UpdateCatalogTokenDialog' import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' import { INTEGRATIONS } from '@/components/interfaces/Integrations/Landing/Integrations.constants' import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' @@ -14,9 +17,16 @@ import { ScaffoldSectionTitle, } from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' +import { useAPIKeys } from '@/data/api-keys/api-keys-query' +import { useVaultSecretDecryptedValueQuery } from '@/data/vault/vault-secret-decrypted-value-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string }) => { + const { data: project } = useSelectedProjectQuery() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') + const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta @@ -24,6 +34,23 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketName }) const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) + const { data: decryptedToken, isSuccess: isSuccessVaultDecrypt } = + useVaultSecretDecryptedValueQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + id: wrapperValues?.vault_token, + }, + { enabled: canReadAPIKeys } + ) + + const { data: apiKeysData, isSuccess: isSuccessApiKeys } = useAPIKeys( + { projectRef: project?.ref, reveal: true }, + { enabled: canReadAPIKeys } + ) + + const isTokenValid = apiKeysData?.allSecretKeys.some((x) => x.api_key === decryptedToken) + if (!wrapperInstance) return null return ( @@ -48,6 +75,17 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string /> </ScaffoldHeader> + {isSuccessApiKeys && isSuccessVaultDecrypt && !isTokenValid && ( + <Admonition type="warning" title="Catalog token is no longer valid" className="mb-4"> + <p> + The Iceberg wrapper's catalog token doesn't match with any of your project's API keys, + and hence authorization will fail when connecting to your bucket. Update the catalog + token to use any of your project's API keys. + </p> + {canReadAPIKeys && <UpdateCatalogTokenDialog vaultTokenId={wrapperValues?.vault_token} />} + </Admonition> + )} + <Card> {wrapperMeta.server.options .filter((option) => !option.hidden && wrapperValues[option.name]) diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx new file mode 100644 index 00000000000..21dc8212a49 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx @@ -0,0 +1,148 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { InlineLink } from '@/components/ui/InlineLink' +import { useAPIKeys } from '@/data/api-keys/api-keys-query' +import { fdwKeys } from '@/data/fdw/keys' +import { vaultSecretsKeys } from '@/data/vault/keys' +import { useVaultSecretUpdateMutation } from '@/data/vault/vault-secret-update-mutation' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export const UpdateCatalogTokenDialog = ({ vaultTokenId }: { vaultTokenId?: string }) => { + const { ref } = useParams() + const queryClient = useQueryClient() + const { data: project } = useSelectedProjectQuery() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') + + const [open, setOpen] = useState(false) + const [selectedKey, setSelectedKey] = useState<string>() + + const { data: apiKeysData, isPending } = useAPIKeys( + { projectRef: ref, reveal: true }, + { enabled: canReadAPIKeys } + ) + const { allSecretKeys } = apiKeysData ?? {} + + const { mutate: updateVaultSecret, isPending: isUpdating } = useVaultSecretUpdateMutation({ + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: fdwKeys.list(ref) }), + queryClient.invalidateQueries({ + queryKey: vaultSecretsKeys.getDecryptedValue(ref, vaultTokenId), + }), + ]) + + toast.success('Successfully updated catalog token!') + setOpen(false) + }, + }) + + const onSubmit = () => { + if (!project) return console.error('Project is required') + if (!vaultTokenId) return toast.error('ID of catalog token is missing') + + updateVaultSecret({ + projectRef: project?.ref, + connectionString: project?.connectionString, + id: vaultTokenId, + secret: selectedKey, + skipClearCache: true, + }) + } + + useEffect(() => { + if (allSecretKeys?.length) setSelectedKey(allSecretKeys[0].api_key) + }, [allSecretKeys]) + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="default" className="mt-1"> + Update catalog token + </Button> + </DialogTrigger> + <DialogContent aria-describedby={undefined}> + <DialogHeader> + <DialogTitle>Update Iceberg wrapper catalog token</DialogTitle> + </DialogHeader> + <DialogSectionSeparator /> + + {allSecretKeys?.length === 0 && ( + <Admonition + type="default" + title="Project has no API secret keys" + className="rounded-none border-x-0 border-t-0" + > + <p> + Create an API secret key from your{' '} + <InlineLink href={`/project/${ref}/settings/api-keys`}>project's settings</InlineLink>{' '} + first + </p> + </Admonition> + )} + <DialogSection> + <FormItemLayout + isReactForm={false} + label="Select a secret key to use as your catalog token" + > + {isPending ? ( + <ShimmeringLoader className="py-4" /> + ) : ( + <Select value={selectedKey} onValueChange={setSelectedKey}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {apiKeysData?.allSecretKeys.map((x) => ( + <SelectItem key={x.id} value={x.api_key}> + {x.prefix} ••• + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + )} + </FormItemLayout> + </DialogSection> + <DialogFooter> + <Button variant="default" disabled={isUpdating} onClick={() => setOpen(false)}> + Cancel + </Button> + <Button + variant="primary" + disabled={!selectedKey} + loading={isUpdating} + onClick={() => onSubmit()} + > + Update token + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx index 4a19e1a2d7a..3bb5374afb7 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx @@ -1,18 +1,17 @@ import { useParams } from 'common' import { uniq } from 'lodash' import { Loader2 } from 'lucide-react' -import Link from 'next/link' import { useRouter } from 'next/router' import { parseAsBoolean, useQueryState } from 'nuqs' import { useEffect, useMemo, useState } from 'react' import { Button, Card, CardContent } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' -import { Admonition } from 'ui-patterns/admonition' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { GenericTableLoader } from 'ui-patterns/ShimmeringLoader' import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal' import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket' import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants' +import { ExtensionNeedsUpgrade, ExtensionNotInstalled, WrapperMissing } from './BucketCallouts' import { BucketHeader } from './BucketHeader' import { CreateTableInstructions } from './CreateTable/CreateTableInstructions' import { NamespaceWithTables } from './NamespaceWithTables' @@ -30,18 +29,10 @@ import { ScaffoldSection, ScaffoldSectionTitle, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import { InlineLink } from '@/components/ui/InlineLink' -import { - DatabaseExtension, - useDatabaseExtensionsQuery, -} from '@/data/database-extensions/database-extensions-query' -import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query' -import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' +import { AlertError } from '@/components/ui/AlertError' +import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query' -import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL } from '@/lib/constants' export const AnalyticBucketDetails = () => { const router = useRouter() @@ -66,30 +57,14 @@ export const AnalyticBucketDetails = () => { const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0) const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0) - const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation() - const { publication, - pipeline, icebergWrapper: wrapperInstance, isLoadingWrapperInstance, } = useAnalyticsBucketAssociatedEntities({ projectRef, bucketId: bucket?.name, }) - const { data, isSuccess: isSuccessPipelineStatus } = useReplicationPipelineStatusQuery( - { projectRef, pipelineId: pipeline?.id }, - { - refetchInterval: (query) => { - const data = query.state.data - if (data?.status.name !== 'started') return 4000 - else return false - }, - } - ) - const pipelineStatus = data?.status.name - const isPipelineRunning = pipelineStatus === 'started' - const isPipelineStopped = ['failed', 'stopped'].includes(pipelineStatus ?? '') const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') @@ -224,69 +199,20 @@ export const AnalyticBucketDetails = () => { ) : null} </> ) : ( - <> - {!!pipeline && !!isSuccessPipelineStatus && !isPipelineRunning && ( - <Admonition - type="note" - layout="horizontal" - className="[&>div]:pl-10 [&>div]:translate-y-[-3px]" - childProps={{ title: { className: 'block capitalize-sentence' } }} - showIcon={isPipelineStopped} - title={ - isPipelineStopped - ? `Replication on the bucket has ${pipelineStatus}` - : `${pipelineStatus} replication on the bucket...` - } - description={ - isPipelineStopped - ? 'Data changes from Postgres tables is currently not streaming to their corresponding analytics bucket table' - : 'Data changes from Postgres tables will resume streaming once pipeline has started' - } - actions={ - <div className="flex items-center gap-x-2"> - <Button asChild variant="default"> - <Link - href={`/project/${projectRef}/database/replication/${pipeline.replicator_id}`} - > - View replication - </Link> - </Button> - {isPipelineStopped && ( - <Button - variant="default" - loading={isStartingPipeline} - onClick={async () => { - if (projectRef) { - await startPipeline({ projectRef, pipelineId: pipeline.id }) - } - }} - > - Restart - </Button> - )} - </div> - } - > - {!isPipelineStopped && ( - <Loader2 size={18} className="absolute top-1.5 left-[3px] animate-spin" /> - )} - </Admonition> - )} - <div className="flex flex-col gap-y-10"> - {namespaces.map(({ namespace, schema, tables }) => ( - <NamespaceWithTables - key={namespace} - namespace={namespace} - sourceType="direct" - schema={schema} - tables={tables as any} - wrapperValues={wrapperValues} - pollIntervalNamespaceTables={pollIntervalNamespaceTables} - setPollIntervalNamespaceTables={setPollIntervalNamespaceTables} - /> - ))} - </div> - </> + <div className="flex flex-col gap-y-10"> + {namespaces.map(({ namespace, schema, tables }) => ( + <NamespaceWithTables + key={namespace} + namespace={namespace} + sourceType="direct" + schema={schema} + tables={tables as any} + wrapperValues={wrapperValues} + pollIntervalNamespaceTables={pollIntervalNamespaceTables} + setPollIntervalNamespaceTables={setPollIntervalNamespaceTables} + /> + ))} + </div> )} </ScaffoldSection> @@ -329,125 +255,3 @@ export const AnalyticBucketDetails = () => { </> ) } - -const ExtensionNotInstalled = ({ - bucketName, - projectRef, - wrapperMeta, - wrappersExtension, -}: { - bucketName?: string - projectRef: string - wrapperMeta: WrapperMeta - wrappersExtension: DatabaseExtension -}) => { - const databaseNeedsUpgrading = - (wrappersExtension?.default_version ?? '') < (wrapperMeta?.minimumExtensionVersion ?? '') - - return ( - <> - <ScaffoldSection isFullWidth> - <Admonition type="warning" title="Missing required extension"> - <p> - The Wrappers extension is required in order to query analytics tables.{' '} - {databaseNeedsUpgrading && - 'Please first upgrade your database and then install the extension.'}{' '} - <InlineLink - href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`} - target="_blank" - rel="noreferrer" - className="text-foreground-lighter hover:text-foreground transition-colors" - > - Learn more - </InlineLink> - </p> - <Button variant="default" asChild className="mt-2" onClick={() => {}}> - <Link - href={ - databaseNeedsUpgrading - ? `/project/${projectRef}/settings/infrastructure` - : `/project/${projectRef}/database/extensions?filter=wrappers` - } - > - {databaseNeedsUpgrading ? 'Upgrade database' : 'Install extension'} - </Link> - </Button> - </Admonition> - </ScaffoldSection> - <SimpleConfigurationDetails bucketName={bucketName} /> - </> - ) -} - -const ExtensionNeedsUpgrade = ({ - bucketName, - projectRef, - wrapperMeta, - wrappersExtension, -}: { - bucketName?: string - projectRef: string - wrapperMeta: WrapperMeta - wrappersExtension: DatabaseExtension -}) => { - // [Joshen] Default version is what's on the DB, so if the installed version is already the default version - // but still doesnt meet the minimum extension version, then DB upgrade is required - const databaseNeedsUpgrading = - wrappersExtension?.installed_version === wrappersExtension?.default_version - - return ( - <> - <ScaffoldSection isFullWidth> - <Admonition type="warning" title="Outdated extension version"> - <p> - The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} - {wrapperMeta.minimumExtensionVersion}. You have version{' '} - {wrappersExtension?.installed_version} installed. Please{' '} - {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension - by disabling and enabling the Wrappers extension. - </p> - <p> - Before reinstalling the wrapper extension, you must first remove all existing wrappers. - Afterward, you can recreate the wrappers. - </p> - <Button asChild variant="default"> - <Link - href={ - databaseNeedsUpgrading - ? `/project/${projectRef}/settings/infrastructure` - : `/project/${projectRef}/database/extensions?filter=wrappers` - } - > - {databaseNeedsUpgrading ? 'Upgrade database' : 'Extensions'} - </Link> - </Button> - </Admonition> - </ScaffoldSection> - <SimpleConfigurationDetails bucketName={bucketName} /> - </> - ) -} - -const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { - const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } = - useIcebergWrapperCreateMutation() - - const onSetupWrapper = async () => { - if (!bucketName) return console.error('Bucket name is required') - await createIcebergWrapper({ bucketName }) - } - - return ( - <> - <ScaffoldSection isFullWidth> - <Admonition type="warning" title="Missing integration"> - <p>The Iceberg Wrapper integration is required in order to query analytics tables.</p> - <Button variant="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}> - Install wrapper - </Button> - </Admonition> - </ScaffoldSection> - <SimpleConfigurationDetails bucketName={bucketName} /> - </> - ) -} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx index 4a6df2b1c3b..d9327afd527 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx @@ -14,7 +14,7 @@ import { SheetFooter, SheetSection, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx index 2fc5305e7cb..ef3505c7b2e 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/index.tsx @@ -16,16 +16,16 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent, PageSectionTitle } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { EmptyBucketState } from '../EmptyBucketState' import { CreateBucketButton } from '../NewBucketButton' import { CreateAnalyticsBucketModal } from './CreateAnalyticsBucketModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { AlphaNotice } from '@/components/ui/AlphaNotice' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' import { useAnalyticsBucketsQuery } from '@/data/storage/analytics-buckets-query' diff --git a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/index.tsx b/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/index.tsx deleted file mode 100644 index f829967b1ac..00000000000 --- a/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useBreakpoint } from 'common' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from 'ui' - -import { BucketFilePickerExplorer } from './BucketFilePickerExplorer' -import { BucketFilePickerStateContextProvider } from './BucketFilePickerState' -import type { Bucket } from '@/data/storage/buckets-query' - -export type BucketsFilePickerDialogProps = { - open: boolean - onOpenChange: (open: boolean) => void - selectedBucket: Bucket - acceptedFileExtensions?: string[] - onSelect: (value: string) => void -} - -export function BucketsFilePickerDialog({ - open, - onOpenChange, - selectedBucket, - acceptedFileExtensions, - onSelect, -}: BucketsFilePickerDialogProps) { - const isMobileLayout = useBreakpoint('lg') - - const handleSelect = (value: string) => { - onSelect(value) - onOpenChange(false) - } - - return ( - <> - {isMobileLayout ? ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent side="bottom" className="flex h-[85vh] flex-col gap-0 p-0"> - <SheetHeader className="flex flex-row items-center gap-2 border-b border-overlay px-4 py-3 text-left"> - <SheetTitle className="min-w-0 flex-1 text-left">Choose a file</SheetTitle> - </SheetHeader> - <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col px-2 pb-3 pt-2"> - <BucketFilePickerStateContextProvider - bucket={selectedBucket} - maxFiles={1} - acceptedFileExtensions={acceptedFileExtensions} - > - <BucketFilePickerExplorer onSelect={handleSelect} /> - </BucketFilePickerStateContextProvider> - </div> - </SheetContent> - </Sheet> - ) : ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent - size="xxlarge" - className="flex h-[85vh] max-h-[500px] max-w-5xl flex-col gap-0 overflow-hidden p-0" - > - <DialogHeader className="flex flex-row items-center gap-2 border-b border-overlay px-6 py-4"> - <DialogTitle className="min-w-0 flex-1">Choose a file</DialogTitle> - </DialogHeader> - <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-3"> - <BucketFilePickerStateContextProvider - bucket={selectedBucket} - maxFiles={1} - acceptedFileExtensions={acceptedFileExtensions} - > - <BucketFilePickerExplorer onSelect={handleSelect} /> - </BucketFilePickerStateContextProvider> - </div> - </DialogContent> - </Dialog> - )} - </> - ) -} diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx index 86390fc6ad9..b527971903e 100644 --- a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx @@ -22,7 +22,7 @@ import { STORAGE_BUCKET_SORT } from '../Storage.constants' import { useStoragePreference } from '../StorageExplorer/useStoragePreference' import { BucketsTable } from './BucketsTable' import type { AllowedBucketType } from './types' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' import { usePaginatedBucketsQuery, type Bucket } from '@/data/storage/buckets-query' diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/index.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/index.tsx deleted file mode 100644 index 5c5fcab384e..00000000000 --- a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useBreakpoint } from 'common' -import { - Button, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from 'ui' - -import { BucketsPicker } from './BucketsPicker' -import type { AllowedBucketType } from './types' -import type { Bucket } from '@/data/storage/buckets-query' - -export type BucketsPickerDialogProps = { - open: boolean - onOpenChange: (open: boolean) => void - acceptedFileExtensions?: string[] - hideUnsupportedFiles?: boolean - onSelect: (bucket: Bucket) => void - allowedBucketType: AllowedBucketType -} - -export function BucketsPickerDialog({ - open, - onOpenChange, - onSelect, - allowedBucketType, -}: BucketsPickerDialogProps) { - const isMobileLayout = useBreakpoint('lg') - - return ( - <> - {isMobileLayout ? ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent side="bottom" className="flex h-[85vh] flex-col gap-0 p-0"> - <SheetHeader className="flex flex-row items-center gap-2 border-b border-overlay px-4 py-3 text-left"> - <Button variant="text" size="tiny" className="shrink-0"> - Buckets - </Button> - <SheetTitle className="min-w-0 flex-1 text-left">Select a bucket</SheetTitle> - </SheetHeader> - <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col px-2 pb-3 pt-2"> - <BucketsPicker onSelectBucket={onSelect} allowedBucketType={allowedBucketType} /> - </div> - </SheetContent> - </Sheet> - ) : ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent - size="xxlarge" - className="flex h-[85vh] max-h-[500px] max-w-5xl flex-col gap-0 overflow-hidden p-0" - > - <DialogHeader className="flex flex-row items-center gap-2 border-b border-overlay px-6 py-4"> - <Button variant="text" size="tiny" className="shrink-0"> - Buckets - </Button> - <DialogTitle className="min-w-0 flex-1">Select a bucket</DialogTitle> - </DialogHeader> - <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-3"> - <BucketsPicker onSelectBucket={onSelect} allowedBucketType={allowedBucketType} /> - </div> - </DialogContent> - </Dialog> - )} - </> - ) -} diff --git a/apps/studio/components/interfaces/Storage/BucketsUpgradePlan.tsx b/apps/studio/components/interfaces/Storage/BucketsUpgradePlan.tsx index d0404546cf7..e8c44e5c574 100644 --- a/apps/studio/components/interfaces/Storage/BucketsUpgradePlan.tsx +++ b/apps/studio/components/interfaces/Storage/BucketsUpgradePlan.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { AnalyticsBucket as AnalyticsBucketIcon, VectorBucket as VectorBucketIcon } from 'icons' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index 939a6eec618..6f120dc6456 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -27,11 +27,17 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa schema: 'storage', }) - const { mutateAsync: deletePolicy, isPending: isDeletingPolicies } = - useDatabasePolicyDeleteMutation() + const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation() const { mutate: deleteBucket, isPending: isDeletingBucket } = useBucketDeleteMutation({ onSuccess: async () => { + // Close the modal and navigate away as soon as the bucket itself is deleted, so + // policy cleanup below (which can be slow) doesn't hold the loading state or block + // the success feedback. + toast.success(`Successfully deleted bucket ${bucket.id}`) + onClose() + if (bucketId) router.push(`/project/${projectRef}/storage/files`) + if (!project) return console.error('Project is required') // Clean up policies from the corresponding bucket that was deleted @@ -42,20 +48,18 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa return policyBucket === bucket.name }) + if (bucketPolicies.length === 0) return + try { await Promise.all( bucketPolicies.map((policy) => deletePolicy({ - projectRef: project?.ref, - connectionString: project?.connectionString, + projectRef: project.ref, + connectionString: project.connectionString, originalPolicy: policy, }) ) ) - - toast.success(`Successfully deleted bucket ${bucket.id}`) - if (!!bucketId) router.push(`/project/${projectRef}/storage/files`) - onClose() } catch (error) { toast.success( `Successfully deleted bucket ${bucket.id}. However, there was a problem deleting the policies tied to the bucket. Please review them in the storage policies section` @@ -76,7 +80,7 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa size="medium" variant="destructive" title={`Delete bucket “${bucket.id}”`} - loading={isDeletingBucket || isDeletingPolicies} + loading={isDeletingBucket} confirmPlaceholder="Type bucket name" confirmString={bucket.id} confirmLabel="Delete bucket" diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index bb827d0e4dc..d0a3a8f047b 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -24,7 +24,7 @@ import { SelectValue, Switch, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx index 22eef639108..edbb38a5c85 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx @@ -10,7 +10,7 @@ import { DialogSectionSeparator, DialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useBucketEmptyMutation } from '@/data/storage/bucket-empty-mutation' import type { Bucket } from '@/data/storage/buckets-query' diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx index 7b152a46c03..2edc4f74694 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx @@ -1,5 +1,5 @@ import { BucketPlus } from 'icons' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { CreateBucketButton } from './NewBucketButton' import { BUCKET_TYPES } from './Storage.constants' diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.LoadMoreRow.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.LoadMoreRow.tsx index ef6eafbef6b..169b5732faf 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.LoadMoreRow.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/BucketsTable.LoadMoreRow.tsx @@ -1,7 +1,7 @@ import { useIntersectionObserver } from '@uidotdev/usehooks' import { useEffect, type ReactNode } from 'react' import { TableCell, TableRow } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { BucketsTablePaginationProps } from './BucketsTable.types' import { VirtualizedTableCell, VirtualizedTableRow } from '@/components/ui/VirtualizedTable' diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx index 94fb01eb6c3..db17240ed84 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx @@ -12,7 +12,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' @@ -25,7 +25,7 @@ import { STORAGE_BUCKET_SORT } from '../Storage.constants' import { useStoragePreference } from '../StorageExplorer/useStoragePreference' import { BucketsTable } from './BucketsTable' import { useFilesBucketsShortcuts } from './useFilesBucketsShortcuts' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' diff --git a/apps/studio/components/interfaces/Storage/Storage.types.ts b/apps/studio/components/interfaces/Storage/Storage.types.ts index e1828531762..3b55265bca9 100644 --- a/apps/studio/components/interfaces/Storage/Storage.types.ts +++ b/apps/studio/components/interfaces/Storage/Storage.types.ts @@ -1,5 +1,5 @@ import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from './Storage.constants' -import type { PolicyFormField } from '@/components/interfaces/Auth/Policies/Policies.types' +import { PolicyFormField } from './StoragePolicies/StoragePolicies.types' export interface StoragePolicyFormField extends PolicyFormField { allowedOperations: string[] diff --git a/apps/studio/components/interfaces/Storage/Storage.utils.ts b/apps/studio/components/interfaces/Storage/Storage.utils.ts index 4bf56bb1569..0ba74d57c70 100644 --- a/apps/studio/components/interfaces/Storage/Storage.utils.ts +++ b/apps/studio/components/interfaces/Storage/Storage.utils.ts @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants' import type { StoragePolicyFormField } from './Storage.types' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' import { convertKVStringArrayToJson } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils' import { FDW } from '@/data/fdw/fdws-query' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyAllowedOperation.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyAllowedOperation.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyAllowedOperation.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyAllowedOperation.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyDefinition.tsx similarity index 93% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyDefinition.tsx index c83b2fe9aa2..b5e40edcdd1 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyDefinition.tsx @@ -4,7 +4,7 @@ import { HelpCircle } from 'lucide-react' import { useEffect } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import SqlEditor from '@/components/ui/SqlEditor' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' interface PolicyDefinitionProps { operation: string @@ -68,7 +68,11 @@ const PolicyDefinition = ({ </p> </div> <div className={`w-2/3 ${showCheck(operation) ? 'h-32' : 'h-56'}`}> - <SqlEditor defaultValue={definition} onInputChange={onUpdatePolicyUsing} /> + <CodeEditor + language="pgsql" + defaultValue={definition} + onInputChange={onUpdatePolicyUsing} + /> </div> </div> )} @@ -108,7 +112,7 @@ const PolicyDefinition = ({ </p> </div> <div className={`w-2/3 ${showUsing(operation) ? 'h-32' : 'h-56'}`}> - <SqlEditor defaultValue={check} onInputChange={onUpdatePolicyCheck} /> + <CodeEditor language="pgsql" defaultValue={check} onInputChange={onUpdatePolicyCheck} /> </div> </div> )} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyEditorFooter.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyEditorFooter.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyEditorFooter.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyEditorFooter.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyName.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyName.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyName.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyName.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyRoles.tsx similarity index 98% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyRoles.tsx index c0e57e7bc6f..0985835ec15 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyRoles.tsx @@ -9,7 +9,7 @@ import { import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { SYSTEM_ROLES } from '@/components/interfaces/Database/Roles/Roles.constants' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditor/index.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/index.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditor/index.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/index.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.constants.ts b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModal.constants.ts similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/Policies.constants.ts rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModal.constants.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModalTitle.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModalTitle.tsx similarity index 93% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModalTitle.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModalTitle.tsx index ba194252cb3..044a4acaffd 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModalTitle.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModalTitle.tsx @@ -2,7 +2,7 @@ import { noop } from 'lodash' import { ChevronLeft, FlaskConical } from 'lucide-react' import { Button } from 'ui' -import { POLICY_MODAL_VIEWS } from '../Policies.constants' +import { POLICY_MODAL_VIEWS } from './PolicyEditorModal.constants' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' @@ -16,7 +16,7 @@ interface PolicyEditorModalTitleProps { onToggleFeaturePreviewModal: () => void } -const PolicyEditorModalTitle = ({ +export const PolicyEditorModalTitle = ({ view, schema, table, @@ -65,5 +65,3 @@ const PolicyEditorModalTitle = ({ </div> ) } - -export default PolicyEditorModalTitle diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/index.tsx similarity index 94% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/index.tsx index 0a59f44c760..7a82ff90e73 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/index.tsx @@ -4,7 +4,10 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Dialog, DialogContent, DialogHeader, DialogTitle } from 'ui' -import { POLICY_MODAL_VIEWS } from '../Policies.constants' +import { PolicyEditor } from '../PolicyEditor' +import { PolicyReview } from '../PolicyReview' +import { PolicySelection } from '../PolicySelection' +import { PolicyTemplates } from '../PolicyTemplates' import { DraftPostgresPolicyCreatePayload, DraftPostgresPolicyUpdatePayload, @@ -12,22 +15,21 @@ import { PolicyForReview, PostgresPolicyCreatePayload, PostgresPolicyUpdatePayload, -} from '../Policies.types' +} from '../StoragePolicies.types' import { createPayloadForCreatePolicy, createPayloadForUpdatePolicy, createSQLPolicy, -} from '../Policies.utils' -import { PolicyEditor } from '../PolicyEditor' -import { PolicyReview } from '../PolicyReview' -import PolicySelection from '../PolicySelection' -import PolicyTemplates from '../PolicyTemplates' -import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants' -import { getGeneralPolicyTemplates } from './PolicyEditorModal.constants' -import PolicyEditorModalTitle from './PolicyEditorModalTitle' +} from '../StoragePolicies.utils' +import { POLICY_MODAL_VIEWS } from './PolicyEditorModal.constants' +import { PolicyEditorModalTitle } from './PolicyEditorModalTitle' import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + getGeneralPolicyTemplates, + type PolicyTemplate, +} from '@/components/interfaces/Database/Policies/Policies.constants' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' // Call only from a user-gesture handler (the Save click). Promotes the draft payload's diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyReview.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyReview.tsx similarity index 80% rename from apps/studio/components/interfaces/Auth/Policies/PolicyReview.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyReview.tsx index c0366f3add0..a22c7c18ab2 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyReview.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyReview.tsx @@ -2,8 +2,8 @@ import { isEmpty, noop } from 'lodash' import { useState } from 'react' import { Button, DialogFooter, DialogSection } from 'ui' -import type { PolicyForReview } from './Policies.types' -import SqlEditor from '@/components/ui/SqlEditor' +import type { PolicyForReview } from './StoragePolicies.types' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' interface PolicyReviewProps { policy: PolicyForReview @@ -22,7 +22,7 @@ export const PolicyReview = ({ onSelectSave() } - let formattedSQLStatement = policy.statement || '' + const formattedSQLStatement = policy.statement || '' return ( <> @@ -44,9 +44,14 @@ export const PolicyReview = ({ </div> ) : ( <div className="space-y-2"> - <span>{policy.description}</span> + <p>{policy.description}</p> <div className="h-40"> - <SqlEditor readOnly defaultValue={formattedSQLStatement} /> + <CodeEditor + hideLineNumbers + isReadOnly + language="pgsql" + defaultValue={formattedSQLStatement} + /> </div> </div> )} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicySelection.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicySelection.tsx similarity index 98% rename from apps/studio/components/interfaces/Auth/Policies/PolicySelection.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicySelection.tsx index c1a7fc666cf..06dc7a89ade 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicySelection.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicySelection.tsx @@ -12,7 +12,7 @@ interface PolicySelectionProps { onToggleFeaturePreviewModal?: () => void } -const PolicySelection = ({ +export const PolicySelection = ({ description = '', showAssistantPreview, onViewTemplates = noop, @@ -89,5 +89,3 @@ const PolicySelection = ({ </DialogSection> ) } - -export default PolicySelection diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/TemplatePreview.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatePreview.tsx similarity index 76% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/TemplatePreview.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatePreview.tsx index a7fd8f01fbe..4e4effeaa91 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/TemplatePreview.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatePreview.tsx @@ -1,7 +1,7 @@ import { isEmpty } from 'lodash' -import { PolicyTemplate } from './PolicyTemplates.constants' -import SqlEditor from '@/components/ui/SqlEditor' +import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' interface TemplatePreviewProps { selectedTemplate: PolicyTemplate @@ -10,7 +10,7 @@ interface TemplatePreviewProps { const TemplatePreview = ({ selectedTemplate }: TemplatePreviewProps) => { const { id, templateName, description, statement } = selectedTemplate return ( - <div className="space-y-8 md:w-[70%]"> + <div className="space-y-8 md:w-[100%]"> {!isEmpty(selectedTemplate) && ( <div className="flex h-full flex-col justify-between"> <div className="my-5 h-full space-y-6 px-6"> @@ -23,7 +23,7 @@ const TemplatePreview = ({ selectedTemplate }: TemplatePreviewProps) => { <div className="space-y-2"> <label className="text-foreground-light text-sm">Policy SQL template:</label> <div className="h-64"> - <SqlEditor readOnly queryId={id} defaultValue={statement} /> + <CodeEditor isReadOnly id={id} language="pgsql" defaultValue={statement} /> </div> </div> </div> diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/TemplatesList.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatesList.tsx similarity index 93% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/TemplatesList.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatesList.tsx index 9e7afbcb273..4ece27c1cc1 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/TemplatesList.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatesList.tsx @@ -1,7 +1,7 @@ import { noop } from 'lodash' import { Menu } from 'ui' -import { PolicyTemplate } from './PolicyTemplates.constants' +import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants' interface TemplatesListProps { templates: PolicyTemplate[] diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/index.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/index.tsx similarity index 91% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/index.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/index.tsx index 201e0adbb9f..4c1157a19df 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/index.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/index.tsx @@ -2,9 +2,9 @@ import { isEmpty, noop } from 'lodash' import { useState } from 'react' import { Button, DialogSectionSeparator } from 'ui' -import { PolicyTemplate } from './PolicyTemplates.constants' import TemplatePreview from './TemplatePreview' import TemplatesList from './TemplatesList' +import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants' interface PolicyTemplatesProps { templates?: PolicyTemplate[] @@ -12,7 +12,7 @@ interface PolicyTemplatesProps { onUseTemplate?: (template: PolicyTemplate) => void } -const PolicyTemplates = ({ +export const PolicyTemplates = ({ templates = [], templatesNote = '', onUseTemplate = noop, @@ -45,5 +45,3 @@ const PolicyTemplates = ({ </div> ) } - -export default PolicyTemplates diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx index 70948cb1bed..79a7110b4b7 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx @@ -3,7 +3,6 @@ import { isEmpty } from 'lodash' import { parseAsString, useQueryState } from 'nuqs' import { useMemo, useState } from 'react' import { toast } from 'sonner' -import { GenericSkeletonLoader } from 'ui-patterns' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -14,17 +13,18 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.utils' -import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow' -import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection' -import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal' +import { PolicyEditorModal } from './PolicyEditorModal' import type { PostgresPolicyCreatePayload, PostgresPolicyUpdatePayload, -} from '@/components/interfaces/Auth/Policies/Policies.types' -import { PolicyEditorModal } from '@/components/interfaces/Auth/Policies/PolicyEditorModal' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +} from './StoragePolicies.types' +import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow' +import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection' +import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' import { useDatabasePolicyCreateMutation } from '@/data/database-policies/database-policy-create-mutation' import { useDatabasePolicyDeleteMutation } from '@/data/database-policies/database-policy-delete-mutation' diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.types.ts b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.types.ts similarity index 95% rename from apps/studio/components/interfaces/Auth/Policies/Policies.types.ts rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.types.ts index 911fb6426fc..4461bfd9fbb 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.types.ts +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.types.ts @@ -58,3 +58,8 @@ export interface DraftPostgresPolicyUpdatePayload extends Omit< definition?: DisplayableSqlFragment check?: DisplayableSqlFragment } + +export interface PolicyForReview { + description?: string + statement?: string +} diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.utils.ts b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.utils.ts new file mode 100644 index 00000000000..a9131a949ad --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.utils.ts @@ -0,0 +1,158 @@ +import type { PGPolicy } from '@supabase/pg-meta' +import { untrustedSql } from '@supabase/pg-meta' +import { has, isEmpty, isEqual } from 'lodash' + +import { + DraftPostgresPolicyCreatePayload, + DraftPostgresPolicyUpdatePayload, + PolicyFormField, + PolicyForReview, +} from './StoragePolicies.types' + +/** + * Returns an array of SQL statements that will preview in the review step of the policy editor + * @param {*} policyFormFields { name, using, check, command } + */ + +export const createSQLPolicy = ( + policyFormFields: PolicyFormField, + originalPolicyFormFields?: PGPolicy +) => { + const { definition, check } = policyFormFields + const formattedPolicyFormFields = { + ...policyFormFields, + definition: definition + ? definition.replace(/\s+/g, ' ').trim() + : definition === undefined + ? null + : definition, + check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check, + } + + if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) { + return createSQLStatementForCreatePolicy(formattedPolicyFormFields) + } + + // If there are no changes, return an empty object + if (isEqual(policyFormFields, originalPolicyFormFields)) { + return {} + } + + // Extract out all the fields that updated + const fieldsToUpdate: any = {} + if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) { + fieldsToUpdate.name = formattedPolicyFormFields.name + } + if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) { + fieldsToUpdate.definition = formattedPolicyFormFields.definition + } + if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) { + fieldsToUpdate.check = formattedPolicyFormFields.check + } + if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) { + fieldsToUpdate.roles = formattedPolicyFormFields.roles + } + + if (!isEmpty(fieldsToUpdate)) { + return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate) + } + + return {} +} + +const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => { + const { name, definition, check, command, schema, table } = policyFormFields + const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles + const description = `Add policy for the ${command} operation under the policy "${name}"` + const statement = [ + `CREATE POLICY "${name}" ON "${schema}"."${table}"`, + `AS PERMISSIVE FOR ${command}`, + `TO ${roles.join(', ')}`, + `${definition ? `USING (${definition})` : ''}`, + `${check ? `WITH CHECK (${check})` : ''}`, + ].join('\n') + + return { description, statement } +} + +const createSQLStatementForUpdatePolicy = ( + policyFormFields: PolicyFormField, + fieldsToUpdate: Partial<PolicyFormField> +): PolicyForReview => { + const { name, schema, table } = policyFormFields + + const definitionChanged = has(fieldsToUpdate, ['definition']) + const checkChanged = has(fieldsToUpdate, ['check']) + const nameChanged = has(fieldsToUpdate, ['name']) + const rolesChanged = has(fieldsToUpdate, ['roles']) + + const parameters = Object.keys(fieldsToUpdate) + const description = `Update policy's ${ + parameters.length === 1 + ? parameters[0] + : `${parameters.slice(0, parameters.length - 1).join(', ')} and ${ + parameters[parameters.length - 1] + }` + } ` + const roles = + (fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[]) + + const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"` + const statement = [ + 'BEGIN;', + ...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []), + ...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []), + ...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []), + ...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []), + 'COMMIT;', + ].join('\n') + + return { description, statement } +} + +// These constructors return DRAFT payloads — `definition`/`check` are still +// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture +// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that +// it was reached via a deliberate user action. +export const createPayloadForCreatePolicy = ( + policyFormFields: PolicyFormField +): DraftPostgresPolicyCreatePayload => { + const { name, schema, table, command, definition, check, roles } = policyFormFields + return { + name, + schema, + table, + action: 'PERMISSIVE', + command: command || undefined, + definition: !definition ? undefined : untrustedSql(definition), + check: !check ? undefined : untrustedSql(check), + roles: roles.length > 0 ? roles : undefined, + } +} + +export const createPayloadForUpdatePolicy = ( + policyFormFields: PolicyFormField, + originalPolicyFormFields: PGPolicy +): DraftPostgresPolicyUpdatePayload => { + const { definition, check } = policyFormFields + const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition + const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check + + const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id } + + if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) { + payload.name = policyFormFields.name + } + if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) { + payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition) + } + if (!isEqual(formattedCheck, originalPolicyFormFields.check)) { + payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck) + } + if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) { + if (policyFormFields.roles.length === 0) payload.roles = ['public'] + else payload.roles = policyFormFields.roles || undefined + } + + return payload +} diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx index 71a38c40a86..45980164747 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx @@ -18,8 +18,8 @@ import { TooltipTrigger, } from 'ui' -import { PolicyRow } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import { PolicyRow } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyRow' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { PUBLIC_BUCKET_TOOLTIP } from '@/components/interfaces/Storage/Storage.constants' import { Bucket } from '@/data/storage/buckets-query' diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx index c0dc1be5a37..ac2243ddc8d 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx @@ -9,7 +9,6 @@ import { type ReactNode, } from 'react' import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { PageSection, @@ -19,10 +18,11 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow' import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { useMainScrollContainer } from '@/components/layouts/MainScrollContainerContext' import { NoSearchResults } from '@/components/ui/NoSearchResults' import { type Bucket } from '@/data/storage/buckets-query' diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx index 1ae1d79785e..070b1c814e4 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx @@ -8,13 +8,13 @@ import { createPayloadsForAddPolicy, createSQLPolicies, } from '../Storage.utils' +import { POLICY_MODAL_VIEWS } from './PolicyEditorModal/PolicyEditorModal.constants' +import { PolicySelection } from './PolicySelection' +import { PolicyTemplates } from './PolicyTemplates' import { STORAGE_POLICY_TEMPLATES } from './StoragePolicies.constants' -import StoragePoliciesEditor from './StoragePoliciesEditor' -import StoragePoliciesReview from './StoragePoliciesReview' +import { StoragePoliciesEditor } from './StoragePoliciesEditor' +import { StoragePoliciesReview } from './StoragePoliciesReview' import { StoragePolicyEditorModalTitle } from './StoragePolicyEditorModalTitle' -import { POLICY_MODAL_VIEWS } from '@/components/interfaces/Auth/Policies/Policies.constants' -import PolicySelection from '@/components/interfaces/Auth/Policies/PolicySelection' -import PolicyTemplates from '@/components/interfaces/Auth/Policies/PolicyTemplates' const newPolicyTemplate: any = { name: '', @@ -171,6 +171,7 @@ export const StoragePoliciesEditPolicyModal = ({ <DialogContent size={view === POLICY_MODAL_VIEWS.SELECTION ? 'medium' : 'xxlarge'} hideClose={view !== POLICY_MODAL_VIEWS.TEMPLATES} + className="flex max-h-[calc(100vh-8rem)] flex-col gap-0 overflow-hidden p-0" > <DialogHeader> <DialogTitle> @@ -182,37 +183,41 @@ export const StoragePoliciesEditPolicyModal = ({ </DialogTitle> </DialogHeader> <DialogSectionSeparator /> - {view === POLICY_MODAL_VIEWS.SELECTION ? ( - <PolicySelection - description="PostgreSQL policies control access to your files and folders" - onViewTemplates={onViewTemplates} - onViewEditor={() => onViewEditor('new')} - showAssistantPreview={false} - /> - ) : view === POLICY_MODAL_VIEWS.EDITOR ? ( - <StoragePoliciesEditor - policyFormFields={policyFormFields} - onViewTemplates={onViewTemplates} - onUpdatePolicyName={onUpdatePolicyName} - onUpdatePolicyDefinition={onUpdatePolicyDefinition} - onToggleOperation={onToggleOperation} - onUpdatePolicyRoles={onUpdatePolicyRoles} - onReviewPolicy={validatePolicyEditorFormFields} - /> - ) : view === POLICY_MODAL_VIEWS.TEMPLATES ? ( - <PolicyTemplates - templates={STORAGE_POLICY_TEMPLATES as any[]} - onUseTemplate={onUseTemplate} - templatesNote={''} - /> - ) : view === POLICY_MODAL_VIEWS.REVIEW ? ( + {view === POLICY_MODAL_VIEWS.REVIEW ? ( <StoragePoliciesReview policyStatements={policyStatementsForReview} onSelectBack={onViewEditor} onSelectSave={onReviewSave} /> ) : ( - <div /> + <div className="min-h-0 flex-1 overflow-y-auto"> + {view === POLICY_MODAL_VIEWS.SELECTION ? ( + <PolicySelection + description="PostgreSQL policies control access to your files and folders" + onViewTemplates={onViewTemplates} + onViewEditor={() => onViewEditor('new')} + showAssistantPreview={false} + /> + ) : view === POLICY_MODAL_VIEWS.EDITOR ? ( + <StoragePoliciesEditor + policyFormFields={policyFormFields} + onViewTemplates={onViewTemplates} + onUpdatePolicyName={onUpdatePolicyName} + onUpdatePolicyDefinition={onUpdatePolicyDefinition} + onToggleOperation={onToggleOperation} + onUpdatePolicyRoles={onUpdatePolicyRoles} + onReviewPolicy={validatePolicyEditorFormFields} + /> + ) : view === POLICY_MODAL_VIEWS.TEMPLATES ? ( + <PolicyTemplates + templates={STORAGE_POLICY_TEMPLATES as any[]} + onUseTemplate={onUseTemplate} + templatesNote={''} + /> + ) : ( + <div /> + )} + </div> )} </DialogContent> </Dialog> diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx index 93e400ed95b..981c35abacd 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx @@ -3,9 +3,9 @@ import { Button, Checkbox, cn, DialogSection, DialogSectionSeparator } from 'ui' import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from '../Storage.constants' import { deriveAllowedClientLibraryMethods } from '../Storage.utils' -import { PolicyName } from '@/components/interfaces/Auth/Policies/PolicyEditor/PolicyName' -import { PolicyRoles } from '@/components/interfaces/Auth/Policies/PolicyEditor/PolicyRoles' -import SqlEditor from '@/components/ui/SqlEditor' +import { PolicyName } from './PolicyEditor/PolicyName' +import { PolicyRoles } from './PolicyEditor/PolicyRoles' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { DOCS_URL } from '@/lib/constants' const PolicyDefinition = ({ definition = '', onUpdatePolicyDefinition = () => {} }) => { @@ -20,7 +20,11 @@ const PolicyDefinition = ({ definition = '', onUpdatePolicyDefinition = () => {} </p> </div> <div className="h-56 md:w-2/3"> - <SqlEditor defaultValue={definition} onInputChange={onUpdatePolicyDefinition} /> + <CodeEditor + language="pgsql" + defaultValue={definition} + onInputChange={onUpdatePolicyDefinition} + /> </div> </div> ) @@ -145,7 +149,7 @@ const PolicyEditorFooter = ({ onViewTemplates = () => {}, onReviewPolicy = () => // [Refactor] All these update methods could be summarised into one single function probably -const StoragePoliciesEditor = ({ +export const StoragePoliciesEditor = ({ policyFormFields = {}, onViewTemplates = noop, onUpdatePolicyName = noop, @@ -190,5 +194,3 @@ const StoragePoliciesEditor = ({ </> ) } - -export default StoragePoliciesEditor diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx index 92f3c5d192a..d4b687a3c5b 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Button, DialogFooter, DialogSection, DialogSectionSeparator } from 'ui' -import SqlEditor from '@/components/ui/SqlEditor' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' const ReviewEmptyState = () => { return ( @@ -17,7 +17,7 @@ interface StoragePoliciesReviewProps { onSelectSave: any } -const StoragePoliciesReview = ({ +export const StoragePoliciesReview = ({ policyStatements = [], onSelectBack = () => {}, onSelectSave = () => {}, @@ -46,9 +46,14 @@ const StoragePoliciesReview = ({ let formattedSQLStatement = policy.statement || '' return ( <div key={`policy_${idx}`} className="space-y-2"> - <span>{policy.description}</span> + <p className="text-sm">{policy.description}</p> <div className="h-40"> - <SqlEditor readOnly defaultValue={formattedSQLStatement} /> + <CodeEditor + hideLineNumbers + isReadOnly + language="pgsql" + defaultValue={formattedSQLStatement} + /> </div> </div> ) @@ -69,5 +74,3 @@ const StoragePoliciesReview = ({ </> ) } - -export default StoragePoliciesReview diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx index 820da1dd73b..7138302232b 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx @@ -3,7 +3,7 @@ import { ChevronLeft, X } from 'lucide-react' import { Dialog as DialogPrimitive } from 'radix-ui' import { cn } from 'ui' -import { POLICY_MODAL_VIEWS } from '@/components/interfaces/Auth/Policies/Policies.constants' +import { POLICY_MODAL_VIEWS } from './PolicyEditorModal/PolicyEditorModal.constants' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx index 97ff713cd8f..f4f54e73b91 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx @@ -44,9 +44,9 @@ import { CreateCredentialModal } from './CreateCredentialModal' import { RevokeCredentialModal } from './RevokeCredentialModal' import { StorageCredItem } from './StorageCredItem' import { getConnectionURL } from './StorageSettings.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' import { useProjectStorageConfigUpdateUpdateMutation } from '@/data/config/project-storage-config-update-mutation' diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageListV2MigrationCallout.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageListV2MigrationCallout.tsx deleted file mode 100644 index 33ae91b104e..00000000000 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageListV2MigrationCallout.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' -import dayjs from 'dayjs' -import { useState } from 'react' -import { toast } from 'sonner' -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - DialogTrigger, -} from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' - -import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { InlineLink } from '@/components/ui/InlineLink' -import { useProjectStorageConfigUpdateUpdateMutation } from '@/data/config/project-storage-config-update-mutation' -import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' - -// [Joshen] Will be decided by Storage team, temp setting to 15th December 2025 UTC (3 months buffer) -const MIGRATION_DEADLINE = '2025-12-15T00:00:00' - -export const StorageListV2MigrationCallout = () => { - const deadline = dayjs(MIGRATION_DEADLINE).utc(true) - const currentDate = dayjs.utc() - const remainingMonths = Math.ceil(deadline.diff(currentDate, 'months', true)) - - return ( - <Admonition - type="note" - title="A new version of Storage is available for your project" - description={ - <> - <p> - Get access to the List-V2 endpoint for improved performance and the ability to enable - Analytics buckets to your storage system. - </p> - {remainingMonths <= 1 && ( - <p> - Your project's Storage will be automatically upgraded by{' '} - <TimestampInfo - displayAs="utc" - utcTimestamp={MIGRATION_DEADLINE} - className="text-sm text-inherit font-medium" - labelFormat="DD MMM YYYY HH:mm (UTC)" - />{' '} - if the upgrade is not completed by then. - </p> - )} - </> - } - actions={<StorageListV2MigrationDialog />} - /> - ) -} - -export const StorageListV2MigratingCallout = () => { - return ( - <Admonition - type="note" - title="Project storage is currently upgrading" - description="This notice will be closed once the upgrade has been completed. Hang tight!" - /> - ) -} - -const StorageListV2MigrationDialog = () => { - const { ref } = useParams() - const { can: canUpdateStorageSettings } = useAsyncCheckPermissions( - PermissionAction.STORAGE_ADMIN_WRITE, - '*' - ) - - const [open, setOpen] = useState(false) - - const { mutate: updateStorageConfig, isPending: isUpdating } = - useProjectStorageConfigUpdateUpdateMutation({ - onSuccess: () => { - toast.success(`Project's storage will be upgraded shortly!`) - setOpen(false) - }, - }) - - const onConfirmUpgrade = () => { - if (!ref) return console.error('Project ref is required') - updateStorageConfig({ projectRef: ref, external: { upstreamTarget: 'canary' } }) - } - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <ButtonTooltip - variant="primary" - disabled={!canUpdateStorageSettings} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateStorageSettings - ? 'You need additional permissions to upgrade storage' - : undefined, - }, - }} - > - Upgrade Storage - </ButtonTooltip> - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Upgrade your project's Storage</DialogTitle> - <DialogDescription> - Get access to Analytics buckets and an improved list method - </DialogDescription> - </DialogHeader> - - <DialogSectionSeparator /> - - <Admonition - type="warning" - className="rounded-none border-x-0 border-t-0" - title="Migration required to optimise the database schema for upgrade" - description="We recommend running the update during periods of lower activity, although minimal to no disruption is expected." - /> - - <DialogSection className="flex flex-col gap-y-2"> - <p className="text-sm"> - Depending on the number of objects in your Storage, the migration can take up to 24 - hours to finish. - </p> - - <p className="text-sm"> - The upgrade will increase your disk size to about 15 - 25% and IOPS will be used to - create new efficient indexes as well as denormalising tables. - </p> - - <p className="text-sm"> - Ensure that your database instance has not{' '} - <InlineLink href={`/project/${ref}/settings/compute-and-disk#disk-size`}> - scaled disk - </InlineLink>{' '} - within the last 6h and you have at least 60%{' '} - <InlineLink href={`/project/${ref}/settings/infrastructure#infrastructure-activity`}> - CPU capacity - </InlineLink>{' '} - before proceeding. - </p> - </DialogSection> - - <DialogFooter> - <Button variant="default" disabled={isUpdating} onClick={() => setOpen(false)}> - Cancel - </Button> - <Button variant="primary" loading={isUpdating} onClick={() => onConfirmUpgrade()}> - Upgrade now - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx index 9c6cf3a62cf..245ef423660 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM, useFlag, useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -29,10 +29,6 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage' -import { - StorageListV2MigratingCallout, - StorageListV2MigrationCallout, -} from './StorageListV2MigrationCallout' import { STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED, STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED, @@ -44,9 +40,9 @@ import { encodeBucketLimitErrorMessage, } from './StorageSettings.utils' import { ValidateSizeLimit } from './StorageSettings.ValidateSizeLimit' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' import { useProjectStorageConfigUpdateUpdateMutation } from '@/data/config/project-storage-config-update-mutation' @@ -70,8 +66,6 @@ export const StorageSettings = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const showMigrationCallout = useFlag('storageMigrationCallout') - const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_READ, '*' @@ -88,10 +82,6 @@ export const StorageSettings = () => { isSuccess, isError, } = useProjectStorageConfigQuery({ projectRef }) - const isListV2UpgradeAvailable = - !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'main' - const isListV2Upgrading = - !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'canary' const { runCondition: sizeLimitCheckCondition, @@ -282,209 +272,200 @@ export const StorageSettings = () => { /> )} {isSuccess && ( - <> - {showMigrationCallout && ( - <> - {isListV2UpgradeAvailable && <StorageListV2MigrationCallout />} - {isListV2Upgrading && <StorageListV2MigratingCallout />} - </> - )} - <form id={formId} onSubmit={form.handleSubmit(onSubmit)}> - <Card> - <CardContent> - <FormField - control={form.control} - name="imageTransformationEnabled" - render={({ field }) => ( - <FormItemLayout - layout="flex-row-reverse" - label="Enable image transformation" - description={ - <> - Optimize and resize images on the fly.{' '} - <InlineLink - href={`${DOCS_URL}/guides/storage/serving/image-transformations`} - > - Learn more - </InlineLink> - . - </> - } - > - <FormControl> - <Switch - size="large" - disabled={ - !hasAccessToImageTransformations || !canUpdateStorageSettings - } - checked={hasAccessToImageTransformations && field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItemLayout> - )} - /> - </CardContent> - - <CardContent> - <FormField - control={form.control} - name="fileSizeLimit" - render={({ field }) => ( - <FormItemLayout - hideMessage - layout="flex-row-reverse" - label="Global file size limit" - description={ - <> - Restrict the size of files uploaded across all buckets.{' '} - <InlineLink - href={`${DOCS_URL}/guides/storage/uploads/file-limits`} - > - Learn more - </InlineLink> - . - {!shouldAutoValidateBucketLimits && ( - <p> - Ensure that the global limit is greater than that of - individual buckets - </p> - )} - </> - } - > - <FormControl> - <div className="flex items-center justify-end"> - <Input - type="number" - {...field} - onChange={(e) => { - field.onChange(e) - form.clearErrors('fileSizeLimit') - }} - className="w-32 rounded-r-none border-r-0" - disabled={ - !hasAccessToFileSizeConfiguration || - !canUpdateStorageSettings - } - /> - <FormField - control={form.control} - name="unit" - render={({ field: unitField }) => ( - <Select - value={unitField.value} - onValueChange={(val) => { - unitField.onChange(val) - form.clearErrors('fileSizeLimit') - }} - disabled={ - !hasAccessToFileSizeConfiguration || - !canUpdateStorageSettings - } - > - <SelectTrigger className="w-[90px] text-xs font-mono rounded-l-none bg-surface-300"> - <SelectValue placeholder="Choose a prefix"> - {storageUnit} - </SelectValue> - </SelectTrigger> - <SelectContent> - {Object.values(StorageSizeUnits).map((unit: string) => ( - <SelectItem - key={unit} - disabled={!hasAccessToFileSizeConfiguration} - value={unit} - > - {unit} - </SelectItem> - ))} - </SelectContent> - </Select> - )} - /> - </div> - </FormControl> - {sizeLimitCheckCondition === 'confirm' && ( - <ValidateSizeLimit - onValidate={sizeLimitCheckQuery} - projectRef={projectRef} - isLoadingBucketEstimate={isBucketEstimatePending} - /> - )} - </FormItemLayout> - )} - /> - {fileSizeLimitError && ( - <FormMessage className="ml-auto mt-2 text-right w-1/2"> - <StorageFileSizeLimitErrorMessage - error={fileSizeLimitError} - projectRef={projectRef} - /> - </FormMessage> - )} - </CardContent> - {hasLimitedStorageAccess && ( - <UpgradeToPro - fullWidth - variant="primary" - source="storageSizeLimit" - featureProposition="configure upload file size limits in Storage" - primaryText="Free Plan has a fixed upload file size limit of 50 MB" - secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes( - STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED - )} and unlock image transformations.`} - /> - )} - {isSpendCapOn && ( - <UpgradeToPro - fullWidth - addon="spendCap" - variant="default" - source="storageSizeLimit" - featureProposition="increase the file upload size limits in Storage" - buttonText="Disable spend cap" - primaryText="Reduced max upload file size limit due to spend cap" - secondaryText={`Disable your spend cap to allow file uploads of up to ${formatBytes( - STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED - )}.`} - /> - )} - - {!canUpdateStorageSettings && ( - <CardContent> - <p className="text-sm text-foreground-light"> - You need additional permissions to update storage settings - </p> - </CardContent> - )} - - <CardFooter className="justify-end space-x-2"> - {form.formState.isDirty && ( - <Button - variant="default" - type="reset" - onClick={() => form.reset()} - disabled={ - !form.formState.isDirty || !canUpdateStorageSettings || isUpdating + <form id={formId} onSubmit={form.handleSubmit(onSubmit)}> + <Card> + <CardContent> + <FormField + control={form.control} + name="imageTransformationEnabled" + render={({ field }) => ( + <FormItemLayout + layout="flex-row-reverse" + label="Enable image transformation" + description={ + <> + Optimize and resize images on the fly.{' '} + <InlineLink + href={`${DOCS_URL}/guides/storage/serving/image-transformations`} + > + Learn more + </InlineLink> + . + </> } > - Cancel - </Button> + <FormControl> + <Switch + size="large" + disabled={ + !hasAccessToImageTransformations || !canUpdateStorageSettings + } + checked={hasAccessToImageTransformations && field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItemLayout> )} + /> + </CardContent> + + <CardContent> + <FormField + control={form.control} + name="fileSizeLimit" + render={({ field }) => ( + <FormItemLayout + hideMessage + layout="flex-row-reverse" + label="Global file size limit" + description={ + <> + Restrict the size of files uploaded across all buckets.{' '} + <InlineLink + href={`${DOCS_URL}/guides/storage/uploads/file-limits`} + > + Learn more + </InlineLink> + . + {!shouldAutoValidateBucketLimits && ( + <p> + Ensure that the global limit is greater than that of + individual buckets + </p> + )} + </> + } + > + <FormControl> + <div className="flex items-center justify-end"> + <Input + type="number" + {...field} + onChange={(e) => { + field.onChange(e) + form.clearErrors('fileSizeLimit') + }} + className="w-32 rounded-r-none border-r-0" + disabled={ + !hasAccessToFileSizeConfiguration || !canUpdateStorageSettings + } + /> + <FormField + control={form.control} + name="unit" + render={({ field: unitField }) => ( + <Select + value={unitField.value} + onValueChange={(val) => { + unitField.onChange(val) + form.clearErrors('fileSizeLimit') + }} + disabled={ + !hasAccessToFileSizeConfiguration || + !canUpdateStorageSettings + } + > + <SelectTrigger className="w-[90px] text-xs font-mono rounded-l-none bg-surface-300"> + <SelectValue placeholder="Choose a prefix"> + {storageUnit} + </SelectValue> + </SelectTrigger> + <SelectContent> + {Object.values(StorageSizeUnits).map((unit: string) => ( + <SelectItem + key={unit} + disabled={!hasAccessToFileSizeConfiguration} + value={unit} + > + {unit} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + /> + </div> + </FormControl> + {sizeLimitCheckCondition === 'confirm' && ( + <ValidateSizeLimit + onValidate={sizeLimitCheckQuery} + projectRef={projectRef} + isLoadingBucketEstimate={isBucketEstimatePending} + /> + )} + </FormItemLayout> + )} + /> + {fileSizeLimitError && ( + <FormMessage className="ml-auto mt-2 text-right w-1/2"> + <StorageFileSizeLimitErrorMessage + error={fileSizeLimitError} + projectRef={projectRef} + /> + </FormMessage> + )} + </CardContent> + {hasLimitedStorageAccess && ( + <UpgradeToPro + fullWidth + variant="primary" + source="storageSizeLimit" + featureProposition="configure upload file size limits in Storage" + primaryText="Free Plan has a fixed upload file size limit of 50 MB" + secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes( + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED + )} and unlock image transformations.`} + /> + )} + {isSpendCapOn && ( + <UpgradeToPro + fullWidth + addon="spendCap" + variant="default" + source="storageSizeLimit" + featureProposition="increase the file upload size limits in Storage" + buttonText="Disable spend cap" + primaryText="Reduced max upload file size limit due to spend cap" + secondaryText={`Disable your spend cap to allow file uploads of up to ${formatBytes( + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED + )}.`} + /> + )} + + {!canUpdateStorageSettings && ( + <CardContent> + <p className="text-sm text-foreground-light"> + You need additional permissions to update storage settings + </p> + </CardContent> + )} + + <CardFooter className="justify-end space-x-2"> + {form.formState.isDirty && ( <Button - variant={hasLimitedStorageAccess ? 'default' : 'primary'} - type="submit" - loading={isUpdating} + variant="default" + type="reset" + onClick={() => form.reset()} disabled={ - !canUpdateStorageSettings || isUpdating || !form.formState.isDirty + !form.formState.isDirty || !canUpdateStorageSettings || isUpdating } > - Save + Cancel </Button> - </CardFooter> - </Card> - </form> - </> + )} + <Button + variant={hasLimitedStorageAccess ? 'default' : 'primary'} + type="submit" + loading={isUpdating} + disabled={ + !canUpdateStorageSettings || isUpdating || !form.formState.isDirty + } + > + Save + </Button> + </CardFooter> + </Card> + </form> )} </> )} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx index d642e0a5689..8bf96c3df3e 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx @@ -23,7 +23,7 @@ import { SheetTitle, SheetTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/RegionLimitation.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/RegionLimitation.tsx index bb7f7cdce1e..d8b133dc5eb 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/RegionLimitation.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/RegionLimitation.tsx @@ -1,12 +1,9 @@ import { VectorBucket } from 'icons' import { AWS_REGIONS } from 'shared-data' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { - EmptyStatePresentational, - PageContainer, - PageSection, - PageSectionContent, -} from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' +import { PageContainer } from 'ui-patterns/PageContainer' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { AVAILABLE_REPLICA_REGIONS } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' import { AlphaNotice } from '@/components/ui/AlphaNotice' diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx index 9f19a022317..d50be9764c4 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx @@ -22,7 +22,7 @@ import { SheetTitle, SheetTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { useS3VectorsWrapperExtension } from '../useS3VectorsWrapper' diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx index e125a56697c..20d6811b4b9 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx @@ -44,7 +44,7 @@ import { ScaffoldSectionDescription, ScaffoldSectionTitle, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useVectorBucketQuery } from '@/data/storage/vector-bucket-query' import { useVectorBucketsIndexesQuery } from '@/data/storage/vector-buckets-indexes-query' diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.tsx index 6bd95e679e3..f5720a9eaf1 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.tsx @@ -1,5 +1,5 @@ import { VectorBucketsLocalDisabledState } from './VectorBucketsLocalDisabledState' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' import type { ResponseError } from '@/types' diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx index b0478b0eda0..b41c76a478b 100644 --- a/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' import { Button, cn, DialogSectionSeparator } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { LinkSupportTicketForm } from './LinkSupportTicketForm' diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx index b9edd290bff..f587e28a2a5 100644 --- a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx +++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx @@ -7,9 +7,9 @@ import Link from 'next/link' import type { UseFormReturn } from 'react-hook-form' import { toast } from 'sonner' import { Button, cn, CommandGroup, CommandItem, FormControl, FormField } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { ExtendedSupportCategories } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' diff --git a/apps/studio/components/interfaces/Support/Success.tsx b/apps/studio/components/interfaces/Support/Success.tsx index 7e43b1cf8c7..3ac45a837e7 100644 --- a/apps/studio/components/interfaces/Support/Success.tsx +++ b/apps/studio/components/interfaces/Support/Success.tsx @@ -65,7 +65,7 @@ export const Success = ({ <Button asChild variant="default" - icon={<IconDiscord size={16} fill="hsl(var(--background-default))" />} + icon={<IconDiscord size={16} fill="var(--background-default)" />} > <Link href="https://discord.supabase.com/" target="_blank"> Join Discord diff --git a/apps/studio/components/interfaces/Support/SupportSuccessSection.tsx b/apps/studio/components/interfaces/Support/SupportSuccessSection.tsx deleted file mode 100644 index d8178166152..00000000000 --- a/apps/studio/components/interfaces/Support/SupportSuccessSection.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { ReactNode } from 'react' -import { cn } from 'ui' - -interface SupportSuccessSectionProps { - icon?: ReactNode - title: string - description: ReactNode - action?: ReactNode - children?: ReactNode - className?: string - headerClassName?: string -} - -export function SupportSuccessSection({ - icon, - title, - description, - action, - children, - className, - headerClassName, -}: SupportSuccessSectionProps) { - const hasIcon = icon !== undefined - - return ( - <div className={cn('w-full py-1', className)}> - <div className="flex flex-col gap-4"> - <div - className={cn( - 'flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between', - headerClassName - )} - > - <div className={cn('flex min-w-0 flex-1 items-start', hasIcon ? 'gap-3' : 'gap-0')}> - {hasIcon && ( - <div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-background"> - {icon} - </div> - )} - <div className="min-w-0 space-y-1"> - <h4 className="text-sm font-medium text-foreground">{title}</h4> - <div className="text-sm text-foreground-light">{description}</div> - </div> - </div> - {action && <div className="shrink-0 sm:ml-4">{action}</div>} - </div> - {children && <div className={cn(hasIcon && 'sm:pl-[52px]')}>{children}</div>} - </div> - </div> - ) -} diff --git a/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx b/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx index a069588c854..76c46d62b13 100644 --- a/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx +++ b/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import { useEffect } from 'react' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' import { useVerifyEmailMutation } from '@/data/support/support-email-verification-mutation' diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index f54d2f3fa50..189c57fc216 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -182,7 +182,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp } return ( - <div className="sb-grid-header__inner"> + <div className="flex items-center space-x-2"> {showHeaderActions && ( <div className="flex items-center gap-x-2"> {isReadOnly && ( diff --git a/apps/studio/components/interfaces/TableGridEditor/IndexAdvisorPopover.tsx b/apps/studio/components/interfaces/TableGridEditor/IndexAdvisorPopover.tsx deleted file mode 100644 index 4c00de4812c..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/IndexAdvisorPopover.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Lightbulb } from 'lucide-react' -import { Button, Popover, PopoverContent, PopoverTrigger } from 'ui' - -import { EnableIndexAdvisorButton } from '../QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton' - -export const IndexAdvisorPopover = () => { - return ( - <Popover modal={false}> - <PopoverTrigger asChild> - <Button variant="default" icon={<Lightbulb strokeWidth={1.5} />}> - Index Advisor - </Button> - </PopoverTrigger> - <PopoverContent className="w-80 text-sm" align="end"> - <h4 className="flex items-center gap-2"> - <Lightbulb size={16} /> Index Advisor - </h4> - <div className="grid gap-2 mt-4 text-foreground-light text-xs"> - <p>Index Advisor recommends indexes to improve query performance on this table.</p> - <p>Enable Index Advisor to get recommendations based on your actual query patterns.</p> - <div className="mt-2"> - <EnableIndexAdvisorButton /> - </div> - </div> - </PopoverContent> - </Popover> - ) -} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx index 636138b553a..6704c06902c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx @@ -1,5 +1,5 @@ import { includes, noop } from 'lodash' -import { Edit, Eye } from 'lucide-react' +import { Edit, Expand, Eye } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -278,11 +278,10 @@ export const InputField = ({ <InputGroupButton variant="default" type="button" + icon={isEditable ? <Edit /> : <Expand />} + className="px-1.5" onClick={() => onEditJson({ column: field.name, value: field.value })} - icon={isEditable ? <Edit /> : <Eye />} - > - {isEditable ? 'Edit' : 'View JSON'} - </InputGroupButton> + /> </InputGroupAddon> </InputGroup> </FormItemLayout> diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx deleted file mode 100644 index 49f9c13dcfe..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import Editor, { OnChange } from '@monaco-editor/react' -import { noop } from 'lodash' - -// [Joshen] Should just use CodeEditor instead of declaring Editor here so that all the mount logic is consistent - -interface JsonCodeEditorProps { - value: string - readOnly?: boolean - onInputChange: OnChange -} - -export const JsonCodeEditor = ({ - value = '', - readOnly = false, - onInputChange = noop, -}: JsonCodeEditorProps) => { - return ( - <Editor - className="monaco-editor" - theme="supabase" - defaultLanguage="json" - value={value} - loading={<h4>Loading</h4>} - options={{ - readOnly, - tabSize: 2, - fontSize: 13, - minimap: { - enabled: false, - }, - wordWrap: 'on', - fixedOverflowWidgets: true, - lineNumbersMinChars: 4, - }} - onMount={(editor) => { - editor.changeViewZones((accessor) => { - accessor.addZone({ - afterLineNumber: 0, - heightInPx: 4, - domNode: document.createElement('div'), - }) - }) - editor.focus() - }} - onChange={onInputChange} - /> - ) -} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx index 1fbc235d376..23b4f9280ca 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx @@ -1,3 +1,4 @@ +import type { OnMount } from '@monaco-editor/react' import { MAX_CHARACTERS } from '@supabase/pg-meta/src/query/table-row-query' import { useParams } from 'common' import { AlignLeft } from 'lucide-react' @@ -8,8 +9,8 @@ import { Button, cn, SidePanel } from 'ui' import { ActionBar } from '../../ActionBar' import { isValueTruncated } from '../RowEditor.utils' import { DrilldownViewer } from './DrilldownViewer/DrilldownViewer' -import { JsonCodeEditor } from './JsonCodeEditor' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle' import { useTableEditorQuery } from '@/data/table-editor/table-editor-query' import { isTableLike } from '@/data/table-editor/table-editor-types' @@ -60,16 +61,33 @@ export const JsonEditor = ({ const { mutate: getCellValue, isPending, isSuccess, reset } = useGetCellValueMutation() - const validateJSON = async (resolve: () => void) => { - try { - const newJsonStr = removeJSONTrailingComma(jsonStr) - const minifiedJSON = minifyJSON(newJsonStr) - if (onSaveJSON) onSaveJSON(minifiedJSON, resolve) - } catch (error: any) { - resolve() - toast.error('JSON seems to have an invalid structure.') - } - } + const validateJSON = useCallback( + async (nextValue: string, resolve: () => void) => { + try { + const newJsonStr = removeJSONTrailingComma(nextValue) + const minifiedJSON = minifyJSON(newJsonStr) + if (onSaveJSON) onSaveJSON(minifiedJSON, resolve) + } catch (error: any) { + resolve() + toast.error('JSON seems to have an invalid structure.') + } + }, + [onSaveJSON] + ) + + const handleEditorMount: OnMount = useCallback( + (editor, monaco) => { + if (readOnly) return + + editor.addAction({ + id: 'save-value', + label: 'Save value', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], + run: () => validateJSON(editor.getValue(), () => undefined), + }) + }, + [readOnly, validateJSON] + ) const prettify = () => { const res = prettifyJSON(jsonStr) @@ -166,18 +184,20 @@ export const JsonEditor = ({ closePanel={onClose} backButtonLabel={backButtonLabel} applyButtonLabel={applyButtonLabel} - applyFunction={readOnly ? undefined : validateJSON} + applyFunction={readOnly ? undefined : (resolve) => validateJSON(jsonStr, resolve)} /> } > <div className="flex flex-auto h-full flex-col gap-y-4 relative"> {view === 'edit' ? ( <div className="w-full h-full grow"> - <JsonCodeEditor + <CodeEditor key={jsonString} - readOnly={readOnly} - onInputChange={(val) => setJsonStr(val ?? '')} + isReadOnly={readOnly} + language="json" value={(jsonStr ?? '').toString()} + onInputChange={(val) => setJsonStr(val ?? '')} + onMount={handleEditorMount} /> </div> ) : ( diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx index ced4a6bc70a..6c4b5a08f37 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx @@ -18,6 +18,7 @@ import { validateFields, } from './RowEditor.utils' import { TextEditor } from './TextEditor' +import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils' import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -144,7 +145,7 @@ export const RowEditor = ({ identifiers[column.name] = column.format === 'bytea' ? convertByteaToHex(row![column.name]) : row![column.name] }) - configuration.identifiers = identifiers + configuration.identifiers = getStableRowIdentifiers(row!, identifiers) configuration.rowIdx = row!.idx } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx index a64bdd1bf68..8a03bca1b88 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx @@ -1,7 +1,6 @@ -import { Editor } from '@monaco-editor/react' +import type { OnMount } from '@monaco-editor/react' import { MAX_CHARACTERS } from '@supabase/pg-meta/src/query/table-row-query' import { useParams } from 'common' -import { Loader } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import remarkGfm from 'remark-gfm' import { toast } from 'sonner' @@ -10,6 +9,7 @@ import { Button, cn, SidePanel } from 'ui' import { ActionBar } from '../ActionBar' import { isValueTruncated } from './RowEditor.utils' import { Markdown } from '@/components/interfaces/Markdown' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle' import { useTableEditorQuery } from '@/data/table-editor/table-editor-query' import { isTableLike } from '@/data/table-editor/table-editor-types' @@ -78,9 +78,26 @@ export const TextEditor = ({ ) } - const saveValue = (resolve: () => void) => { - if (onSaveField) onSaveField(strValue, resolve) - } + const saveValue = useCallback( + (nextValue: string, resolve: () => void) => { + if (onSaveField) onSaveField(nextValue, resolve) + }, + [onSaveField] + ) + + const handleEditorMount: OnMount = useCallback( + (editor, monaco) => { + if (readOnly) return + + editor.addAction({ + id: 'save-value', + label: 'Save value', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], + run: () => saveValue(editor.getValue(), () => undefined), + }) + }, + [readOnly, saveValue] + ) useEffect(() => { if (visible) { @@ -122,42 +139,20 @@ export const TextEditor = ({ closePanel={onClose} backButtonLabel="Cancel" applyButtonLabel="Save value" - applyFunction={readOnly ? undefined : saveValue} + applyFunction={readOnly ? undefined : (resolve) => saveValue(strValue, resolve)} /> } > <div className="relative flex flex-auto h-full flex-col gap-y-4"> {view === 'edit' ? ( <div className="w-full h-full grow"> - <Editor + <CodeEditor key={value} - theme="supabase" - className="monaco-editor" - defaultLanguage="markdown" - value={strValue} - loading={<Loader className="animate-spin" strokeWidth={2} size={20} />} - options={{ - readOnly, - tabSize: 2, - fontSize: 13, - minimap: { - enabled: false, - }, - wordWrap: 'on', - fixedOverflowWidgets: true, - lineNumbersMinChars: 4, - }} - onMount={(editor) => { - editor.changeViewZones((accessor) => { - accessor.addZone({ - afterLineNumber: 0, - heightInPx: 4, - domNode: document.createElement('div'), - }) - }) - editor.focus() - }} - onChange={(val) => setStrValue(val ?? '')} + isReadOnly={readOnly} + language="markdown" + value={strValue ?? ''} + onInputChange={(val) => setStrValue(val ?? '')} + onMount={handleEditorMount} /> </div> ) : ( diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 42d782b88ec..05a0d70b445 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -35,11 +35,8 @@ import { import { TableEditor } from './TableEditor/TableEditor' import type { ImportContent } from './TableEditor/TableEditor.types' import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations' +import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils' import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' -import { - acceptGeneratedPolicy, - type GeneratedPolicy, -} from '@/components/interfaces/Auth/Policies/Policies.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { databasePoliciesKeys } from '@/data/database-policies/keys' import { useDatabasePublicationCreateMutation } from '@/data/database-publications/database-publications-create-mutation' @@ -82,7 +79,6 @@ type SaveTableParamsBase = { columns: ColumnField[] foreignKeyRelations: ForeignKey[] resolve: () => void - generatedPolicies?: GeneratedPolicy[] } type SaveTableParamsNew = SaveTableParamsBase & { @@ -318,7 +314,7 @@ export const SidePanelEditor = ({ const { row, column } = selectedValueForJsonEdit payload = { [column]: value === null ? null : JSON.parse(value as any) } selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) - configuration = { identifiers, rowIdx: row.idx } + configuration = { identifiers: getStableRowIdentifiers(row!, identifiers), rowIdx: row.idx } } else if (snap.sidePanel?.type === 'cell') { const column = snap.sidePanel.value?.column const row = snap.sidePanel.value?.row @@ -326,7 +322,7 @@ export const SidePanelEditor = ({ if (!column || !row) return payload = { [column]: value === null ? null : value } selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) - configuration = { identifiers, rowIdx: row.idx } + configuration = { identifiers: getStableRowIdentifiers(row!, identifiers), rowIdx: row.idx } } if (payload !== undefined && configuration !== undefined) { @@ -354,7 +350,10 @@ export const SidePanelEditor = ({ }) const isNewRecord = false - const configuration = { identifiers, rowIdx: row.idx } + const configuration = { + identifiers: getStableRowIdentifiers(row, identifiers), + rowIdx: row.idx, + } await saveRow(value, isNewRecord, configuration, (error) => { if (error) { @@ -594,7 +593,6 @@ export const SidePanelEditor = ({ configuration, columns, foreignKeyRelations, - generatedPolicies = [], resolve, }: SaveTableParams) => { let toastId @@ -659,7 +657,6 @@ export const SidePanelEditor = ({ 'table.has_rls': isRLSEnabled ? 1 : 0, 'table.has_foreign_keys': foreignKeyRelations.length > 0 ? 1 : 0, 'table.has_import': importContent !== undefined ? 1 : 0, - 'table.generated_policies_count': generatedPolicies.length, 'project.region': project?.region ?? 'local', ...(project?.cloud_provider && { 'project.cloud_provider': project.cloud_provider, @@ -670,13 +667,7 @@ export const SidePanelEditor = ({ }) try { - // The Save click is the explicit user gesture that promotes generated policy - // SQL (programmatic or AI) to executable. Programmatic fragments are already - // SafeSqlFragment; AI fragments are UntrustedSqlFragment — both are accepted - // here before being passed into createTable. - const acceptedPolicies = generatedPolicies.map(acceptGeneratedPolicy) - - const { table, failedPolicies } = await createTable({ + const { table } = await createTable({ projectRef: project?.ref!, connectionString: project?.connectionString, toastId, @@ -685,13 +676,10 @@ export const SidePanelEditor = ({ foreignKeyRelations, isRLSEnabled, importContent, - generatedPolicies: acceptedPolicies, - onCreatePoliciesSuccess: () => track('rls_generated_policies_created'), track, }) createTableSpan.setAttribute('table.created', 1) - createTableSpan.setAttribute('table.failed_policies', failedPolicies.length) await Sentry.startSpan( { name: 'create_table.post_creation', op: 'db.table.post_creation' }, @@ -732,24 +720,7 @@ export const SidePanelEditor = ({ } ) - // Show success toast after everything is complete - if (failedPolicies.length > 0) { - toast.success( - `Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`, - { - id: toastId, - description: ( - <ul className="list-disc pl-6"> - {failedPolicies.map((x) => ( - <li key={x.name}>{x.name}</li> - ))} - </ul> - ), - } - ) - } else { - toast.success(`Table ${table.name} is good to go!`, { id: toastId }) - } + toast.success(`Table ${table.name} is good to go!`, { id: toastId }) onTableCreated(table) } catch (error) { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts index 02e843e0f2c..2f5b366797a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts @@ -132,7 +132,6 @@ describe('createTable', () => { }) expect(mockTrack).toHaveBeenCalledWith('table_created', { - has_generated_policies: false, method: 'table_editor', schema_name: 'public', table_name: 'test_table', @@ -148,7 +147,6 @@ describe('createTable', () => { ) expect(result).toStrictEqual({ - failedPolicies: [], table: mockTableResult, }) }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index ac043ff81a7..63b72069d9d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/nextjs' +import type { PGTablePrimaryKey } from '@supabase/pg-meta' import pgMeta, { getAddForeignKeySQL, getAddPrimaryKeySQL, @@ -11,7 +12,6 @@ import pgMeta, { getUpdateIdentitySequenceSQL, type ForeignKey, } from '@supabase/pg-meta' -import type { PGTablePrimaryKey } from '@supabase/pg-meta' import { joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' import { Query } from '@supabase/pg-meta/src/query' import { chunk, find, isEmpty, isEqual } from 'lodash' @@ -26,12 +26,10 @@ import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './Si import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils' import type { ImportContent } from './TableEditor/TableEditor.types' import type { SupaRow } from '@/components/grid/types' -import { type AcceptedGeneratedPolicy } from '@/components/interfaces/Auth/Policies/Policies.utils' -import SparkBar from '@/components/ui/SparkBar' +import { SparkBar } from '@/components/ui/SparkBar' import { createDatabaseColumn } from '@/data/database-columns/database-column-create-mutation' import { deleteDatabaseColumn } from '@/data/database-columns/database-column-delete-mutation' import { updateDatabaseColumn } from '@/data/database-columns/database-column-update-mutation' -import { createDatabasePolicy } from '@/data/database-policies/database-policy-create-mutation' import type { Constraint } from '@/data/database/constraints-query' import { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' import { databaseKeys } from '@/data/database/keys' @@ -443,8 +441,6 @@ export const createTable = async ({ foreignKeyRelations, isRLSEnabled, importContent, - generatedPolicies = [], - onCreatePoliciesSuccess, track, }: { projectRef: string @@ -459,8 +455,6 @@ export const createTable = async ({ foreignKeyRelations: ForeignKey[] isRLSEnabled: boolean importContent?: ImportContent - generatedPolicies?: AcceptedGeneratedPolicy[] - onCreatePoliciesSuccess?: () => void track: Track }) => { const queryClient = getQueryClient() @@ -545,51 +539,10 @@ export const createTable = async ({ } ) - // 6. Create generated RLS policies if any - // [Joshen] Possible area for optimization to create all policies in a single query call - // Can be subsequently added to the table creation SQL as well for a single transaction - - const failedPolicies: AcceptedGeneratedPolicy[] = [] - if (generatedPolicies.length > 0 && isRLSEnabled) { - await Sentry.startSpan( - { name: 'create_table.create_policies', op: 'db.policies.create' }, - async (span) => { - span.setAttribute('policies.count', generatedPolicies.length) - toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId }) - await Promise.all( - generatedPolicies.map(async (policy) => { - try { - return await createDatabasePolicy({ - projectRef, - connectionString, - payload: { - name: policy.name, - table: policy.table, - schema: policy.schema, - definition: policy.definition, - check: policy.check, - action: policy.action, - command: policy.command, - roles: policy.roles, - }, - }) - } catch (error: any) { - console.error('Failed to generate policy', error.message) - failedPolicies.push(policy) - } - }) - ) - span.setAttribute('policies.failed_count', failedPolicies.length) - onCreatePoliciesSuccess?.() - } - ) - } - track('table_created', { method: 'table_editor', schema_name: payload.schema, table_name: payload.name, - has_generated_policies: generatedPolicies.length > 0 && isRLSEnabled, }) if (isRLSEnabled) { @@ -726,7 +679,7 @@ export const createTable = async ({ ) // Finally, return the created table - return { table, failedPolicies } + return { table } } /** TODO: Refactor to do in a single transaction */ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetFileUpload.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetFileUpload.tsx index 2b5e465c415..80175c081a9 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetFileUpload.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadSheetFileUpload.tsx @@ -2,7 +2,7 @@ import { FileText, Loader } from 'lucide-react' import { DragEvent, useCallback, useRef, useState, type ChangeEvent } from 'react' import { Button, cn } from 'ui' -import SparkBar from '@/components/ui/SparkBar' +import { SparkBar } from '@/components/ui/SparkBar' interface SpreadSheetFileUploadProps { parseProgress: number diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx index bd8894b58c4..ebd6b5c413d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx @@ -2,7 +2,13 @@ import type { PGTable } from '@supabase/pg-meta' import { noop } from 'lodash' import { useCallback, type ChangeEvent, type DragEvent } from 'react' import { toast } from 'sonner' -import { SidePanel, Tabs } from 'ui' +import { + SidePanel, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, +} from 'ui' import { ActionBar } from '../ActionBar' import type { ImportContent } from '../TableEditor/TableEditor.types' @@ -112,8 +118,12 @@ export const SpreadsheetImport = ({ > <SidePanel.Content> <div className="pt-6"> - <Tabs block type="pills" activeId={tab} onChange={handleSwitchTab}> - <Tabs.Panel id="fileUpload" label="Upload CSV"> + <Tabs value={tab} onValueChange={handleSwitchTab}> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="fileUpload">Upload CSV</TabsTrigger> + <TabsTrigger value="pasteText">Paste text</TabsTrigger> + </TabsList> + <TabsContent value="fileUpload"> <SpreadsheetFileDropZone file={hasAttachedFile(state) ? state.file : undefined} parseProgress={ @@ -122,13 +132,13 @@ export const SpreadsheetImport = ({ onUploadFile={handleUploadFile} onRemoveFile={handleRemoveFile} /> - </Tabs.Panel> - <Tabs.Panel id="pasteText" label="Paste text"> + </TabsContent> + <TabsContent value="pasteText"> <SpreadsheetInputZone text={hasAttachedText(state) ? state.text : ''} onInputChange={handleInputText} /> - </Tabs.Panel> + </TabsContent> </Tabs> </div> </SidePanel.Content> diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/useSpreadsheetImport.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/useSpreadsheetImport.ts index 0aaa35a8971..fc231f0dd3e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/useSpreadsheetImport.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/useSpreadsheetImport.ts @@ -4,7 +4,7 @@ import { toast } from 'sonner' import type { SpreadsheetData } from './SpreadsheetImport.types' import { parseSpreadsheet, parseSpreadsheetText } from './SpreadsheetImport.utils' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useTableEditorStateSnapshot } from '@/state/table-editor' interface NoFileState { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx index c1d276ac102..756de361299 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx @@ -13,7 +13,7 @@ import { } from 'react' import { usePreviousDistinct } from 'react-use' import { Button, Switch } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { getApiEndpoint } from '@/components/interfaces/Integrations/DataApi/DataApi.utils' @@ -320,7 +320,7 @@ export const ApiAccessToggle = ({ <div className="space-y-3"> <div className="flex items-center justify-between gap-3"> <div> - <h5>Data API Access</h5> + <h5>Data API access</h5> <p className="text-sm text-foreground-lighter"> Allow this table to be queried via Supabase client libraries or the API directly </p> diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx index bbc84257119..d4c2ef0de94 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx @@ -269,7 +269,7 @@ export const Column = ({ size="small" value={column.defaultValue ?? ''} disabled={column.format.includes('int') && column.isIdentity} - className={`rounded-sm bg-surface-100 lg:gap-0 ${ + className={`rounded-sm lg:gap-0 ${ column.format.includes('int') && column.isIdentity ? 'opacity-50' : '' }`} suggestions={suggestions} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.tsx index 63fc7626aa3..504bae7822a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.tsx @@ -7,7 +7,7 @@ import type { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.typ import type { TableField } from '../TableEditor.types' import { ForeignKeyRow } from './ForeignKeyRow' import { checkIfRelationChanged } from './ForeignKeysManagement.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query' import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index a7fb5b04002..a8313d0cd1a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -2,7 +2,7 @@ import { isEmpty, noop } from 'lodash' import { useContext, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Badge, Checkbox, Input, SidePanel } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ActionBar } from '../ActionBar' @@ -242,7 +242,6 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, - generatedPolicies: [], }) } else if (isDuplicating) { const payload: SaveTablePayloadFor<'duplicate'> = { @@ -256,7 +255,6 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, - generatedPolicies: [], }) } else { const payload: SaveTablePayloadFor<'update'> = { @@ -271,7 +269,6 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, - generatedPolicies: [], }) } } else { diff --git a/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx b/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx index ec1b2ad97e1..5044db1a0a2 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx @@ -1,18 +1,16 @@ -import Editor from '@monaco-editor/react' import { useParams } from 'common' -import { useTheme } from 'next-themes' import Link from 'next/link' -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { Footer } from '@/components/grid/components/footer/Footer' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { useTableDefinitionQuery } from '@/data/database/table-definition-query' import { useViewDefinitionQuery } from '@/data/database/view-definition-query' import { Entity, isTableLike, isViewLike } from '@/data/table-editor/table-editor-types' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' -import { timeout } from '@/lib/helpers' export interface TableDefinitionProps { entity?: Entity @@ -20,9 +18,6 @@ export interface TableDefinitionProps { export const TableDefinition = ({ entity }: TableDefinitionProps) => { const { ref } = useParams() - const editorRef = useRef(null) - const monacoRef = useRef(null) - const { resolvedTheme } = useTheme() const { data: project } = useSelectedProjectQuery() const viewResult = useViewDefinitionQuery( @@ -55,24 +50,6 @@ export const TableDefinition = ({ entity }: TableDefinitionProps) => { [definition] ) - const handleEditorOnMount = async (editor: any, monaco: any) => { - editorRef.current = editor - monacoRef.current = monaco - - // add margin above first line - editor.changeViewZones((accessor: any) => { - accessor.addZone({ - afterLineNumber: 0, - heightInPx: 4, - domNode: document.createElement('div'), - }) - }) - - // when editor did mount, it will need a delay before focus() works properly - await timeout(500) - editor?.focus() - } - if (isLoading) { return ( <div className="h-full grid"> @@ -98,23 +75,8 @@ export const TableDefinition = ({ entity }: TableDefinitionProps) => { Open in SQL Editor </Link> </Button> - <Editor - className="monaco-editor" - theme={resolvedTheme?.includes('dark') ? 'vs-dark' : 'vs'} - onMount={handleEditorOnMount} - defaultLanguage="pgsql" - value={formattedDefinition} - path={''} - options={{ - domReadOnly: true, - readOnly: true, - tabSize: 2, - fontSize: 13, - minimap: { enabled: false }, - wordWrap: 'on', - fixedOverflowWidgets: true, - }} - /> + + <CodeEditor isReadOnly language="pgsql" value={formattedDefinition} /> </div> <Footer /> diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts index 1b7419f1326..e79948922f9 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts @@ -181,7 +181,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { describe('TableEntity.utils: getTablePoliciesUrl', () => { it('builds the policies url for plain schema and name values', () => { expect(getTablePoliciesUrl('abc', 'public', 'users')).toBe( - '/project/abc/auth/policies?search=users&schema=public' + '/project/abc/database/policies?search=users&schema=public' ) }) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts index ea9d789cc4d..dc09cc2b9d3 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts @@ -29,7 +29,7 @@ export const getTablePoliciesUrl = ( schema: string | undefined, name: string | undefined ): string => { - return `/project/${projectRef ?? ''}/auth/policies?search=${encodeURIComponent( + return `/project/${projectRef ?? ''}/database/policies?search=${encodeURIComponent( name ?? '' )}&schema=${encodeURIComponent(schema ?? '')}` } diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index c25fe9e79ec..b0a2f2d4c54 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -5,7 +5,8 @@ import { useRouter } from 'next/router' import { parseAsString, useQueryState } from 'nuqs' import { useCallback } from 'react' import { Button } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import DeleteConfirmationDialogs from './DeleteConfirmationDialogs' import { SidePanelEditor } from './SidePanelEditor/SidePanelEditor' diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/BlockField.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/BlockField.tsx deleted file mode 100644 index fa70470b0e7..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/BlockField.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Skeleton } from 'ui' - -import { BlockFieldProps } from '../../types' -import { TruncatedTextWithPopover } from './TruncatedTextWithPopover' -import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' -import { DataTableSheetRowAction } from '@/components/ui/DataTable/DataTableSheetRowAction' - -export const BlockField = ({ - config, - data, - enrichedData, - isLoading, - filterFields, - table, -}: BlockFieldProps) => { - const value = config.getValue(data, enrichedData) - const displayValue = value ?? 'No value' - const stringValue = String(displayValue) - - const shouldShowSkeleton = config.requiresEnrichedData && isLoading && !value - - const filterField = filterFields.find((field) => field.value === config.id) - const isFilterable = !!filterField - - // Special handling for status field and key fields - const isStatusField = config.id === 'status' - const isApiKeyField = config.id === 'api_key_role' - - // Truncation config - const maxLength = config.maxLength || 50 // Default to 50 characters - - const fieldContent = ( - <> - <dt className="text-[13.5px] text-foreground-light">{config.label}</dt> - <dd className="text-right"> - {shouldShowSkeleton ? ( - <Skeleton className="h-4 w-24" /> - ) : isStatusField && displayValue !== 'N/A' ? ( - <DataTableColumnStatusCode value={displayValue} level={data?.level} className="text-xs" /> - ) : isApiKeyField && displayValue !== 'N/A' ? ( - <TruncatedTextWithPopover - text={stringValue} - maxLength={maxLength} - className="px-2 py-1 text-xs font-mono border border-border rounded-sm bg-surface-100" - /> - ) : displayValue !== 'N/A' ? ( - <TruncatedTextWithPopover - text={stringValue} - maxLength={maxLength} - className={`text-sm font-mono ${ - isFilterable ? 'text-foreground cursor-pointer hover:underline' : 'text-foreground' - }`} - /> - ) : ( - <span - className={`text-sm font-mono ${ - isFilterable ? 'text-foreground cursor-pointer hover:underline' : 'text-foreground' - } text-foreground-light`} - > - {displayValue} - </span> - )} - </dd> - </> - ) - - if (isFilterable && !shouldShowSkeleton && displayValue !== 'N/A') { - return ( - <DataTableSheetRowAction - fieldValue={config.id} - filterFields={filterFields} - value={stringValue} - table={table} - className="flex justify-between items-center py-1 px-2 rounded-sm hover:bg-accent/50 cursor-pointer w-full hover:bg-surface-400" - > - {fieldContent} - </DataTableSheetRowAction> - ) - } - - return <div className="flex justify-between items-center py-1 px-2">{fieldContent}</div> -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx deleted file mode 100644 index c210412ea46..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/FieldWithSeeMore.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Table } from '@tanstack/react-table' -import { X } from 'lucide-react' -import { useState } from 'react' -import { Badge, Collapsible, CollapsibleContent, CollapsibleTrigger, Skeleton } from 'ui' - -import { BlockFieldConfig } from '../../types' -import { BlockField } from './BlockField' -import { DataTableFilterField } from '@/components/ui/DataTable/DataTable.types' - -// Single source of truth for field row styling -export const FieldRow = ({ - label, - value, - expandButton, -}: { - label: string - value: React.ReactNode - expandButton?: React.ReactNode -}) => ( - <div className="flex justify-between items-center py-1 px-2"> - <dt className="flex items-center gap-2 text-[13.5px] text-foreground-light"> - <span>{label}</span> - {expandButton} - </dt> - <dd className="text-right">{value}</dd> - </div> -) - -interface FieldWithSeeMoreProps { - primaryField: BlockFieldConfig - additionalFields: BlockFieldConfig[] - data: any - enrichedData?: any - isLoading?: boolean - filterFields: DataTableFilterField<any>[] - table: Table<any> - showValueAsBadge?: boolean -} - -// Primary field with expandable additional details -export const FieldWithSeeMore = ({ - primaryField, - additionalFields, - data, - enrichedData, - isLoading, - filterFields, - table, - showValueAsBadge = false, -}: FieldWithSeeMoreProps) => { - const [isOpen, setIsOpen] = useState(false) - const hasAdditionalData = additionalFields.some( - (field) => field.getValue(data, enrichedData) && field.getValue(data, enrichedData) !== 'N/A' - ) - - const primaryValue = primaryField.getValue(data, enrichedData) - const shouldShowSkeleton = primaryField.requiresEnrichedData && isLoading && !primaryValue - const isApiKeyField = primaryField.id === 'api_key_role' - - // Common value rendering logic - const renderValue = () => { - if (shouldShowSkeleton) { - return <Skeleton className="h-4 w-24" /> - } - - if (isApiKeyField && primaryValue && primaryValue !== 'N/A') { - return ( - <span className="border border-border rounded-sm px-2 py-1 bg-surface-100 text-xs font-mono text-foreground"> - {primaryValue} - </span> - ) - } - - if (showValueAsBadge && primaryValue && primaryValue !== 'N/A') { - return <Badge variant="secondary">{primaryValue}</Badge> - } - - return ( - <span - className={`text-sm font-mono text-foreground ${ - primaryValue === 'N/A' ? 'text-foreground-light' : '' - }`} - > - {primaryValue ?? 'N/A'} - </span> - ) - } - - const expandButton = hasAdditionalData ? ( - <CollapsibleTrigger asChild> - <button className="w-3 h-3 flex items-center justify-center selection:font-mono text-foreground-lighter text-xs hover:text-foreground-light bg-foreground-muted/75 rounded-sm [&[data-state=open]>svg]:rotate-180 hover:bg-foreground-lighter"> - <X size={10} className="text-background-surface-400 rotate-45" strokeWidth={3} /> - </button> - </CollapsibleTrigger> - ) : undefined - - return ( - <div className="border-t border-border"> - {hasAdditionalData ? ( - <Collapsible open={isOpen} onOpenChange={setIsOpen}> - <FieldRow label={primaryField.label} value={renderValue()} expandButton={expandButton} /> - <CollapsibleContent className="transition-all data-closed:animate-collapsible-up data-open:animate-collapsible-down"> - <div className="mt-1"> - {additionalFields.map((field) => ( - <BlockField - key={field.id} - config={field} - data={data} - enrichedData={enrichedData} - isLoading={isLoading} - filterFields={filterFields} - table={table} - /> - ))} - </div> - </CollapsibleContent> - </Collapsible> - ) : ( - <FieldRow label={primaryField.label} value={renderValue()} /> - )} - </div> - ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TruncatedTextWithPopover.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TruncatedTextWithPopover.tsx deleted file mode 100644 index 809f06a994e..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/TruncatedTextWithPopover.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState } from 'react' -import { Popover, PopoverContent, PopoverTrigger } from 'ui' - -interface TruncatedTextWithPopoverProps { - text: string - maxLength?: number - className?: string - children?: React.ReactNode -} - -export const TruncatedTextWithPopover = ({ - text, - maxLength = 50, - className = '', - children, -}: TruncatedTextWithPopoverProps) => { - const [isOpen, setIsOpen] = useState(false) - const isTruncated = text.length > maxLength - const truncatedText = isTruncated ? text.substring(0, maxLength) + '...' : text - - // If not truncated, just render the text without popover - if (!isTruncated) { - return children ? <>{children}</> : <span className={className}>{text}</span> - } - - // If truncated, render with popover - return ( - <Popover open={isOpen} onOpenChange={setIsOpen}> - <PopoverTrigger asChild> - <span className={`cursor-help ${className}`}>{children || truncatedText}</span> - </PopoverTrigger> - <PopoverContent className="max-w-md p-3 wrap-break-word" side="top"> - <div className="text-sm font-mono whitespace-pre-wrap">{text}</div> - </PopoverContent> - </Popover> - ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/config/fieldHelpers.ts b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/config/fieldHelpers.ts deleted file mode 100644 index a96245f8f53..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/config/fieldHelpers.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { BlockFieldConfig } from '../types' - -/** - * Common field configuration helpers to reduce duplication - */ - -// Helper for simple fields that fallback from enrichedData to data -export const createField = ( - id: string, - label: string, - enrichedKey?: string, - dataKey?: string, - requiresEnrichedData = false -): BlockFieldConfig => ({ - id, - label, - getValue: (data, enrichedData) => { - const eKey = enrichedKey || id - const dKey = dataKey || id - return enrichedData?.[eKey] || data?.[dKey] - }, - requiresEnrichedData, -}) - -// Helper for truncated text fields -export const createTruncatedField = ( - id: string, - label: string, - maxLength: number, - enrichedKey?: string, - dataKey?: string, - requiresEnrichedData = false -): BlockFieldConfig => ({ - id, - label, - getValue: (data, enrichedData) => { - const eKey = enrichedKey || id - const dKey = dataKey || id - const value = enrichedData?.[eKey] || data?.[dKey] - if (!value || typeof value !== 'string') return value - return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value - }, - requiresEnrichedData, -}) - -// Helper for ID fields that show truncated version -export const createIdField = ( - id: string, - label: string, - truncateLength = 8, - enrichedKey?: string, - dataKey?: string, - requiresEnrichedData = false -): BlockFieldConfig => ({ - id, - label, - getValue: (data, enrichedData) => { - const eKey = enrichedKey || id - const dKey = dataKey || id - const value = enrichedData?.[eKey] || data?.[dKey] - if (!value) return null - const stringValue = String(value) - return truncateLength ? `${stringValue.substring(0, truncateLength)}...` : stringValue - }, - requiresEnrichedData, -}) - -// Helper for time/duration fields -export const createTimeField = ( - id: string, - label: string, - unit = 'ms', - enrichedKey?: string, - dataKey?: string, - requiresEnrichedData = false -): BlockFieldConfig => ({ - id, - label, - getValue: (data, enrichedData) => { - const eKey = enrichedKey || id - const dKey = dataKey || id - const time = enrichedData?.[eKey] || data?.[dKey] - return time ? `${time}${unit}` : null - }, - requiresEnrichedData, -}) - -// Helper for status fields with fallback logic -export const createStatusField = ( - id: string = 'status', - label: string = 'Status' -): BlockFieldConfig => ({ - id, - label, - getValue: (data, enrichedData) => enrichedData?.status || data?.status, -}) - -// Helper for path/filename extraction -export const createFileNameField = ( - id: string, - label: string, - pathKey = 'path', - requiresEnrichedData = false -): BlockFieldConfig => ({ - id, - label, - getValue: (data, enrichedData) => { - const path = enrichedData?.[pathKey] || data?.[pathKey] - if (!path) return null - // Remove query parameters and hash, get last segment - const cleanPath = path.split('?')[0].split('#')[0] - const segments = cleanPath.split('/') - return segments[segments.length - 1] || null - }, - requiresEnrichedData, -}) - -// Helper for conditional fields based on error status -export const createConditionalField = ( - id: string, - label: string, - getValue: (data: any, enrichedData?: any) => any, - errorValue: string = 'Unavailable', - deletedValue: string = 'Object deleted', - requiresEnrichedData = false -): BlockFieldConfig => ({ - id, - label, - getValue: (data, enrichedData) => { - const status = enrichedData?.status || data?.status - const numStatus = Number(status) - - if (status === 404 || status === '404') { - return deletedValue - } else if (numStatus >= 400) { - return errorValue - } - - return getValue(data, enrichedData) - }, - requiresEnrichedData, -}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index 8abac9cca6d..36dd514026a 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -16,17 +16,21 @@ import { export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] as const -export const LOG_TYPES = [ - 'postgres', - 'postgrest', - 'auth', - 'storage', - 'edge function', - 'realtime', - 'supavisor', - 'pgbouncer', -] as const -export const DEFAULT_LOG_TYPES = ['postgres', 'postgrest'] as const +export const LOG_TYPES_LABELS = { + edge: 'API Gateway', + postgres: 'Postgres', + postgrest: 'PostgREST', + auth: 'Auth', + storage: 'Storage', + 'edge function': 'Edge Function', + realtime: 'Realtime', + supavisor: 'Supavisor', + pgbouncer: 'PgBouncer', +} + +type LogType = keyof typeof LOG_TYPES_LABELS +export const LOG_TYPES = Object.keys(LOG_TYPES_LABELS) as [LogType, ...LogType[]] +export const DEFAULT_LOG_TYPES = ['postgres', 'edge'] as const const parseAsSort = createParser({ parse(queryValue: string) { @@ -68,7 +72,10 @@ export const SEARCH_PARAMS_PARSER = { id: parseAsString, // View options - hide_connection_logs: parseAsBoolean.withDefault(true), + show_connection_logs: parseAsBoolean.withDefault(true), + edge_auth: parseAsBoolean.withDefault(true), + edge_storage: parseAsBoolean.withDefault(true), + edge_postgrest: parseAsBoolean.withDefault(true), } const POSTGRES_STATUS_CODE_LABELS = { diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx index ef131636e14..4ae39344de1 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -1,6 +1,6 @@ import { cn } from 'ui' -import { LOG_TYPES, METHODS, STATUS_CODE_LABELS } from './UnifiedLogs.constants' +import { LOG_TYPES_LABELS, METHODS, STATUS_CODE_LABELS } from './UnifiedLogs.constants' import { ColumnSchema } from './UnifiedLogs.schema' import { getLevelLabel } from './UnifiedLogs.utils' import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' @@ -22,43 +22,32 @@ export const filterFields = [ value: 'log_type', type: 'checkbox', defaultOpen: true, - options: LOG_TYPES.map((type) => ({ label: type, value: type })), + options: Object.entries(LOG_TYPES_LABELS).map(([value, label]) => ({ + label, + value, + options: + // [Joshen] Nested options are treated as just boolean toggles atm for simplicity + // Refer to DataTableFilterCheckbox for their logic + value === 'edge' + ? [ + { label: 'Auth', value: 'edge_auth' }, + { label: 'Storage', value: 'edge_storage' }, + { label: 'Postgrest', value: 'edge_postgrest' }, + ] + : value === 'postgres' + ? [{ label: 'Connection logs', value: 'show_connection_logs' }] + : [], + })), component: (props: Option) => { return ( - <div className="flex w-full items-center justify-between gap-2"> - <span className="capitalize text-foreground/70 group-hover:text-accent-foreground text-xs"> - {props.label.replace('_', ' ')} + <div className="flex items-center w-full justify-between gap-2"> + <span className="text-foreground/70 group-hover:text-accent-foreground text-xs"> + {props.label} </span> </div> ) }, }, - { - label: 'Status', - value: 'status', - type: 'checkbox', - defaultOpen: true, - options: [], - hasDynamicOptions: true, - component: (props: Option) => { - if (typeof props.value === 'boolean') return null - if (typeof props.value === 'undefined') return null - - const statusValue = String(props.value) - const statusLabel = STATUS_CODE_LABELS[statusValue as keyof typeof STATUS_CODE_LABELS] - - return ( - <div className="flex items-center gap-2 w-full min-w-0"> - <span className="shrink-0 text-foreground">{statusValue}</span> - {statusLabel && ( - <span className="text-[0.7rem] text-foreground-lighter truncate" title={statusLabel}> - {statusLabel} - </span> - )} - </div> - ) - }, - }, { label: 'Level', value: 'level', @@ -81,11 +70,37 @@ export const filterFields = [ ) }, }, + { + label: 'Status', + value: 'status', + type: 'checkbox', + defaultOpen: false, + options: [], + hasDynamicOptions: true, + component: (props: Option) => { + if (typeof props.value === 'boolean') return null + if (typeof props.value === 'undefined') return null + + const statusValue = String(props.value) + const statusLabel = STATUS_CODE_LABELS[statusValue as keyof typeof STATUS_CODE_LABELS] + + return ( + <div className="flex items-center gap-2 w-full min-w-0"> + <span className="shrink-0 text-foreground">{statusValue}</span> + {statusLabel && ( + <span className="text-[0.7rem] text-foreground-lighter truncate" title={statusLabel}> + {statusLabel} + </span> + )} + </div> + ) + }, + }, { label: 'Method', value: 'method', type: 'checkbox', - defaultOpen: true, + defaultOpen: false, options: METHODS.map((method) => ({ label: method, value: method })), component: (props: Option) => { return ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts index ba73e93c0b4..69b419ec01e 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts @@ -17,28 +17,28 @@ const withFilters = (...entries: string[]) => ({ ...baseSearch, filter: entries describe('UnifiedLogs.queries (OTEL flat)', () => { describe('getUnifiedLogsQuery', () => { - it('defaults to postgres + postgrest log types when none specified', () => { + it('defaults to postgres + edge log types when none specified', () => { const sql = getUnifiedLogsQuery(baseSearch) - expect(sql).toContain(`source = 'postgres_logs'`) - // postgrest = edge_logs filtered by /rest/ path - expect(sql).toContain( - `source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/rest/%'` - ) - }) - - it('routes the `edge` log type to edge_logs without /rest/ or /storage/ paths', () => { - const sql = getUnifiedLogsQuery(withFilters('log_type:eq:edge')) - expect(sql).toContain(`NOT LIKE '%/rest/%'`) - expect(sql).toContain(`NOT LIKE '%/storage/%'`) const where = sql.split(/\bWHERE\b/)[1] ?? '' - expect(where).not.toContain(`source = 'postgres_logs'`) + expect(where).toContain(`source = 'postgres_logs'`) + expect(where).toContain(`source = 'edge_logs'`) + expect(where).not.toContain(`source = 'postgrest_logs'`) }) - it('routes the `storage` log type to edge_logs filtered by /storage/', () => { + it('routes the `postgrest` log type solely to postgrest_logs (mutually exclusive from edge_logs)', () => { + const sql = getUnifiedLogsQuery(withFilters('log_type:eq:postgrest')) + const where = sql.split(/\bWHERE\b/)[1] ?? '' + expect(where).toContain(`source = 'postgrest_logs'`) + expect(where).not.toContain(`source = 'edge_logs'`) + expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/rest/%'`) + }) + + it('routes the `storage` log type solely to storage_logs (mutually exclusive from edge_logs)', () => { const sql = getUnifiedLogsQuery(withFilters('log_type:eq:storage')) - expect(sql).toContain( - `source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/storage/%'` - ) + const where = sql.split(/\bWHERE\b/)[1] ?? '' + expect(where).toContain(`source = 'storage_logs'`) + expect(where).not.toContain(`source = 'edge_logs'`) + expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/storage/%'`) }) it('escapes single quotes in filter values to prevent SQL injection', () => { @@ -111,19 +111,58 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { expect(sql).not.toContain(`'%error%%'`) }) - it('excludes connection log messages by default (hide_connection_logs=true)', () => { - const sql = getUnifiedLogsQuery({ ...baseSearch, hide_connection_logs: true } as any) + it('excludes connection log messages when show_connection_logs=false', () => { + const sql = getUnifiedLogsQuery({ ...baseSearch, show_connection_logs: false } as any) expect(sql).toContain("source != 'postgres_logs'") expect(sql).toContain("event_message NOT LIKE 'connection received%'") expect(sql).toContain("event_message NOT LIKE 'connection authenticated%'") expect(sql).toContain("event_message NOT LIKE 'connection authorized%'") }) - it('includes connection log messages when hide_connection_logs=false', () => { - const sql = getUnifiedLogsQuery({ ...baseSearch, hide_connection_logs: false } as any) + it('includes connection log messages by default (show_connection_logs=true)', () => { + const sql = getUnifiedLogsQuery({ ...baseSearch, show_connection_logs: true } as any) expect(sql).not.toContain("event_message NOT LIKE 'connection received%'") }) + it.each([ + ['edge_auth', '%/auth/%'], + ['edge_storage', '%/storage/%'], + ['edge_postgrest', '%/rest/%'], + ] as const)('excludes %s-pathed requests from edge_logs when %s=false', (key, pathFilter) => { + const sql = getUnifiedLogsQuery({ ...baseSearch, [key]: false } as any) + expect(sql).toContain("source != 'edge_logs'") + expect(sql).toContain(`log_attributes['request.path'] NOT LIKE '${pathFilter}'`) + }) + + it('does not filter edge_logs by service path by default (all edge_* toggles true)', () => { + const sql = getUnifiedLogsQuery(baseSearch) + expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/auth/%'") + expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/storage/%'") + expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'") + }) + + it('leaves dedicated auth_logs/storage_logs/postgrest_logs rows untouched by the edge_* toggles', () => { + // These toggles only hide traffic nested inside the `edge_logs` (API + // Gateway) source — the dedicated sources are separate log types now + // that log types are mutually exclusive, so they shouldn't be scoped by + // a `source != 'edge_logs' OR ...` guard meant for gateway rows. + const sql = getUnifiedLogsQuery({ + ...baseSearch, + edge_auth: false, + edge_storage: false, + edge_postgrest: false, + } as any) + expect(sql).toContain( + "(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/auth/%')" + ) + expect(sql).toContain( + "(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/storage/%')" + ) + expect(sql).toContain( + "(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/rest/%')" + ) + }) + it('does not emit subqueries or CTEs (rejected by the OTEL endpoint)', () => { const sql = getUnifiedLogsQuery(baseSearch) expect(sql).not.toMatch(/WITH\s+\w+\s+AS\s*\(/i) @@ -144,7 +183,7 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { expect(sql).toContain('arrayJoin([') expect(sql).toContain('multiIf(') expect(sql).toContain(`facet = 'total', 'all'`) - for (const lt of ['edge', 'postgrest', 'storage', 'postgres', 'edge function', 'auth']) { + for (const lt of ['postgrest', 'storage', 'postgres', 'edge function', 'auth']) { expect(sql).toContain(`'${lt}'`) } for (const lvl of ['success', 'warning', 'error']) { @@ -157,17 +196,36 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { }) it('honours an active log_type filter in the total count scan', () => { - const sql = getLogsCountQuery(withFilters('log_type:eq:edge')) + const sql = getLogsCountQuery(withFilters('log_type:eq:storage')) // Assert on the WHERE only: value expressions mention other sources inline. const totalWhere = whereOfBranchContaining(sql, `'all'`) - expect(totalWhere).toContain(`source = 'edge_logs'`) + expect(totalWhere).toContain(`source = 'storage_logs'`) + expect(totalWhere).not.toContain(`source = 'edge_logs'`) expect(totalWhere).not.toContain(`source = 'postgres_logs'`) }) it('gives the log_type facet its own scan that excludes the log_type filter', () => { - const sql = getLogsCountQuery(withFilters('log_type:eq:edge')) + const sql = getLogsCountQuery(withFilters('log_type:eq:postgrest')) const logTypeWhere = whereOfBranchContaining(sql, `'log_type'`) - expect(logTypeWhere).not.toContain(`NOT LIKE '%/rest/%'`) + expect(logTypeWhere).not.toContain(`LIKE '%/rest/%'`) + }) + + it('applies the connection-logs filter to every count scan so badges match the list', () => { + const sql = getLogsCountQuery({ ...baseSearch, show_connection_logs: false } as any) + const scans = sql.split(/\bUNION ALL\b/) + expect(scans.length).toBeGreaterThan(1) + for (const scan of scans) { + expect(scan).toContain("event_message NOT LIKE 'connection received%'") + } + }) + + it('applies the edge_* service filters to every count scan so badges match the list', () => { + const sql = getLogsCountQuery({ ...baseSearch, edge_postgrest: false } as any) + const scans = sql.split(/\bUNION ALL\b/) + expect(scans.length).toBeGreaterThan(1) + for (const scan of scans) { + expect(scan).toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'") + } }) }) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 30121d41b21..2968e77238b 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -55,9 +55,9 @@ const HTTP_STATUS_EXPR: SafeLogSqlFragment = safeSql`if(source = 'auth_logs', lo * logs; the UI surfaces gateway HTTP traffic for those buckets. */ const LOG_TYPE_CONDITION: Record<string, SafeLogSqlFragment> = { - edge: safeSql`source = 'edge_logs' AND ${ATTR.path} NOT LIKE '%/rest/%' AND ${ATTR.path} NOT LIKE '%/storage/%'`, - postgrest: safeSql`source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%'`, - storage: safeSql`source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%'`, + edge: safeSql`source = 'edge_logs'`, + postgrest: safeSql`source = 'postgrest_logs'`, + storage: safeSql`source = 'storage_logs'`, postgres: safeSql`source = 'postgres_logs'`, 'edge function': safeSql`source = 'function_edge_logs'`, auth: safeSql`source = 'auth_logs'`, @@ -67,9 +67,11 @@ const LOG_TYPE_CONDITION: Record<string, SafeLogSqlFragment> = { } // Derived `log_type` column for SELECT / GROUP BY / countIf use. +// WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest' +// WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage' const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE - WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest' - WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage' + WHEN source = 'postgrest_logs' THEN 'postgrest' + WHEN source = 'storage_logs' THEN 'storage' WHEN source = 'edge_logs' THEN 'edge' WHEN source = 'postgres_logs' THEN 'postgres' WHEN source = 'function_edge_logs' THEN 'edge function' @@ -292,22 +294,54 @@ const buildBaseWhere = ( } } + const searchParamsFilter = applySearchParamsFilter(search) + if (searchParamsFilter) parts.push(searchParamsFilter) + return parts } +// Path substrings that identify which downstream service an `edge_logs` +// (API Gateway) row was routed to. Mirrors the convention already used by +// the sibling Logs Explorer (Logs.constants.ts / Logs.utils.otel.ts) and by +// ServiceFlow.sql.ts within this same feature. +const EDGE_SERVICE_PATH_FILTER: Record<'edge_auth' | 'edge_storage' | 'edge_postgrest', string> = { + edge_auth: '%/auth/%', + edge_storage: '%/storage/%', + edge_postgrest: '%/rest/%', +} + /** - * Returns a WHERE condition that excludes Postgres connection lifecycle messages. - * Applied only to the row query and chart query so sidebar facets remain unaffected - * (the OTEL endpoint's UNION ALL count query fails silently when this condition is - * included there, returning empty facet data). + * Returns view-option WHERE conditions — toggles from the filter sidebar that + * hide a subset of rows without being a `filter` URL param (Postgres + * connection lifecycle messages, and per-service traffic nested inside the + * API Gateway `edge_logs` source). Shared by every query via `buildBaseWhere`, + * so the row list, chart and sidebar facet counts stay in sync (otherwise the + * badges over-count by the rows the list hides). */ -const connectionLogsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment | null => { - if (!search.hide_connection_logs) return null - return safeSql`(source != 'postgres_logs' OR ( - event_message NOT LIKE 'connection received%' AND - event_message NOT LIKE 'connection authenticated%' AND - event_message NOT LIKE 'connection authorized%' - ))` +const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment | null => { + const conditions: SafeLogSqlFragment[] = [] + + // Visible by default — only an explicit `false` hides connection logs. + if (search.show_connection_logs === false) { + conditions.push(safeSql`(source != 'postgres_logs' OR ( + event_message NOT LIKE 'connection received%' AND + event_message NOT LIKE 'connection authenticated%' AND + event_message NOT LIKE 'connection authorized%' + ))`) + } + + // Visible by default — only an explicit `false` hides that service's + // requests within the API Gateway log type. + for (const key of ['edge_auth', 'edge_storage', 'edge_postgrest'] as const) { + if (search[key] === false) { + conditions.push( + safeSql`(source != 'edge_logs' OR ${ATTR.path} NOT LIKE ${lit(EDGE_SERVICE_PATH_FILTER[key])})` + ) + } + } + + if (conditions.length === 0) return null + return safeSql`(${joinSqlFragments(conditions, ' AND ')})` } /** @@ -315,8 +349,6 @@ const connectionLogsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment */ export const getUnifiedLogsQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { const conditions = buildBaseWhere(search) - const connFilter = connectionLogsFilter(search) - if (connFilter) conditions.push(connFilter) return safeSql` SELECT ${rowProjection()} FROM logs @@ -442,8 +474,6 @@ export const getLogsChartQuery = (search: QuerySearchParamsType): SafeLogSqlFrag const truncationLevel = calculateChartBucketing(search) const truncFn = truncationFunction(truncationLevel) const conditions = buildBaseWhere(search) - const connFilter = connectionLogsFilter(search) - if (connFilter) conditions.push(connFilter) return safeSql` SELECT diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 6d261b9ddce..ebbb41527bb 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -29,7 +29,6 @@ import { import { RefreshButton } from '../../ui/DataTable/RefreshButton' import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Columns' -import { ConnectionLogsToggle } from './components/ConnectionLogsToggle' import { DownloadLogsButton } from './components/DownloadLogsButton' import { LogsFilterBar } from './components/LogsFilterBar' import { LogsListPanel } from './components/LogsListPanel' @@ -70,7 +69,7 @@ import { useShortcut } from '@/state/shortcuts/useShortcut' export const CHART_CONFIG = { success: { label: <TooltipLabel level="success" />, - color: 'hsl(var(--foreground-muted))', + color: 'var(--foreground-muted)', }, warning: { label: <TooltipLabel level="warning" />, @@ -377,7 +376,6 @@ export const UnifiedLogs = () => { isFilterBarOpen={isFilterBarOpen} setIsFilterBarOpen={setIsFilterBarOpen} dateRangeDisabled={{ after: new Date() }} - afterFilters={<ConnectionLogsToggle />} /> <ResizableHandle withHandle /> <ResizablePanel @@ -461,7 +459,7 @@ export const UnifiedLogs = () => { className={cn( 'h-full [&>div]:h-full', '[&_thead_th]:[border-top:none]! [&_thead_th]:[border-bottom:none]!', - '[&_thead_th]:[box-shadow:inset_0_-1px_0_hsl(var(--border-default))]!', + '[&_thead_th]:[box-shadow:inset_0_-1px_0_var(--border-default)]!', '[&_thead_th]:text-foreground-lighter! [&_thead_tr:hover]:bg-surface-75', '[&_thead_tr]:border-b-0! [&_tbody_tr]:border-b-0!' )} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts new file mode 100644 index 00000000000..431ce1bb0b7 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { buildUnifiedLogsUrl } from './UnifiedLogs.utils' + +describe('buildUnifiedLogsUrl', () => { + const parse = (url: string) => { + const [path, query] = url.split('?') + return { path, params: new URLSearchParams(query) } + } + + it('targets the project logs route with a log_type filter', () => { + const { path, params } = parse(buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'postgres' })) + expect(path).toBe('/project/abc/logs') + expect(params.get('filter')).toBe('log_type:eq:postgres') + expect(params.has('date')).toBe(false) + }) + + it('preserves multi-word log types once decoded', () => { + const { params } = parse(buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'edge function' })) + expect(params.get('filter')).toBe('log_type:eq:edge function') + }) + + it('adds the date range as an epoch-ms pair when start and end are provided', () => { + const start = new Date('2026-05-08T00:00:00.000Z') + const end = new Date('2026-05-08T01:00:00.000Z') + const { params } = parse( + buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'auth', start, end }) + ) + expect(params.get('date')).toBe(`${start.valueOf()}-${end.valueOf()}`) + }) + + it('accepts ISO strings for the date range', () => { + const start = '2026-05-08T00:00:00.000Z' + const end = '2026-05-08T01:00:00.000Z' + const { params } = parse( + buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'auth', start, end }) + ) + expect(params.get('date')).toBe(`${new Date(start).valueOf()}-${new Date(end).valueOf()}`) + }) + + it('omits the date range when only one bound is provided', () => { + const { params } = parse( + buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'storage', start: new Date() }) + ) + expect(params.has('date')).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts index bc2c09fea07..21421486332 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -1,9 +1,31 @@ import { type Table as TTable } from '@tanstack/react-table' import { cn } from 'ui' +import { LOG_TYPES_LABELS } from './UnifiedLogs.constants' import { FacetMetadataSchema } from './UnifiedLogs.schema' import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' +export type UnifiedLogType = keyof typeof LOG_TYPES_LABELS + +export const buildUnifiedLogsUrl = ({ + projectRef, + logType, + start, + end, +}: { + projectRef: string + logType: UnifiedLogType + start?: string | Date + end?: string | Date +}) => { + const params = new URLSearchParams() + params.append('filter', `log_type:eq:${logType}`) + if (start && end) { + params.set('date', `${new Date(start).valueOf()}-${new Date(end).valueOf()}`) + } + return `/project/${projectRef}/logs?${params.toString()}` +} + export const getFacetedUniqueValues = <TData>(facets?: Record<string, FacetMetadataSchema>) => { return (_table: TTable<TData>, columnId: string) => { return new Map(facets?.[columnId]?.rows?.map(({ value, total }) => [value, total]) || []) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx index dede3b3cb4f..f42e66ef45f 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogsBanner.tsx @@ -1,30 +1,42 @@ +import { useParams } from 'common' import { CircleHelpIcon, Undo2 } from 'lucide-react' +import { useRouter } from 'next/router' import { Badge, Button, cn } from 'ui' +import { + useFeaturePreviewModal, + useUnifiedLogsPreview, +} from '../App/FeaturePreview/FeaturePreviewContext' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' interface UnifiedLogsBannerProps { - variant: 'promo' | 'utility' - onEnable?: () => void - onMoreInfo?: () => void - onSwitchBack?: () => void className?: string } -export function UnifiedLogsBanner({ - variant, - onEnable, - onMoreInfo, - onSwitchBack, - className, -}: UnifiedLogsBannerProps) { +export function UnifiedLogsBanner({ className = 'mx-4 mt-4' }: UnifiedLogsBannerProps) { + const router = useRouter() + const { ref } = useParams() + + const { selectFeaturePreview } = useFeaturePreviewModal() + const { enable, disable, isDefaultOptIn, isEnabled } = useUnifiedLogsPreview() + const cardClassName = cn( 'rounded-lg border p-4 space-y-3 text-left', 'bg-muted/10 border-border/50', className ) - if (variant === 'utility') { + const onSwitchBack = () => { + disable() + router.push(`/project/${ref}/logs/explorer`) + } + + const onEnable = () => { + enable() + router.push(`/project/${ref}/logs`) + } + + if (isEnabled && !isDefaultOptIn) { return ( <div className={cn('rounded-lg border px-4 py-3', 'bg-muted/10 border-border/50', className)}> <div className="flex items-center justify-between gap-3"> @@ -50,16 +62,28 @@ export function UnifiedLogsBanner({ </div> <h3 className="font-medium text-sm text-foreground">Introducing unified logs</h3> <div className="flex justify-start items-start gap-x-2"> - <Button variant="default" onClick={onEnable}> - Enable preview - </Button> - <ButtonTooltip - variant="default" - className="px-1.5" - icon={<CircleHelpIcon />} - onClick={onMoreInfo} - tooltip={{ content: { side: 'bottom', text: 'More information' } }} - /> + {isDefaultOptIn ? ( + <Button + variant="default" + icon={<CircleHelpIcon />} + onClick={() => selectFeaturePreview('supabase-ui-preview-unified-logs')} + > + More information + </Button> + ) : ( + <> + <Button variant="default" onClick={onEnable}> + Enable preview + </Button> + <ButtonTooltip + variant="default" + className="px-1.5" + icon={<CircleHelpIcon />} + onClick={() => selectFeaturePreview('supabase-ui-preview-unified-logs')} + tooltip={{ content: { side: 'bottom', text: 'More information' } }} + /> + </> + )} </div> </div> ) diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx deleted file mode 100644 index 4f902b66f1b..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { User } from 'lucide-react' -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from 'ui/src/components/shadcn/ui/hover-card' - -interface AuthUserHoverCardProps { - authUser: string -} - -export const AuthUserHoverCard = ({ authUser }: AuthUserHoverCardProps) => { - if (!authUser) return null - - return ( - <HoverCard closeDelay={0}> - <HoverCardTrigger className="flex items-center justify-center bg-surface-100 border rounded-full"> - <User size={14} className="text-foreground-lighter" /> - </HoverCardTrigger> - <HoverCardContent className="w-80 p-4"> - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <User size={20} className="text-foreground" /> - <h4 className="font-medium">User Details</h4> - </div> - <div className="space-y-1"> - <div className="text-sm"> - <span className="font-medium">ID/Email:</span> {authUser} - </div> - {/* Additional user details can be added here if available */} - </div> - </div> - </HoverCardContent> - </HoverCard> - ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index 24226623f0f..20af0e5e7e3 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -56,7 +56,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { enableHiding: false, enableResizing: false, enableSorting: false, - filterFn: (_row, _columnId, _filterValue) => true, + filterFn: () => true, size: 48, minSize: 48, maxSize: 48, @@ -76,7 +76,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { enableHiding: false, enableResizing: false, enableSorting: false, - filterFn: (_row, _columnId, _filterValue) => true, + filterFn: () => true, size: 48, minSize: 48, maxSize: 48, @@ -93,7 +93,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { const date = new Date(row.getValue<ColumnSchema['date']>('date')) return <HoverCardTimestamp date={date} /> }, - filterFn: (_row, _columnId, _filterValue) => true, + filterFn: () => true, enableResizing: false, enableSorting: false, size: 140, @@ -118,7 +118,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { ) }, enableHiding: false, - filterFn: (_row, _columnId, _filterValue) => true, + filterFn: () => true, enableResizing: false, enableSorting: false, size: 40, @@ -169,7 +169,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { </div> ) }, - filterFn: (_row, _columnId, _filterValue) => true, + filterFn: () => true, enableResizing: false, enableSorting: false, size: 70, @@ -206,6 +206,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { { accessorKey: 'pathname', header: 'Pathname', + filterFn: () => true, enableSorting: false, enableResizing: false, size: 250, diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/ConnectionLogsToggle.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/ConnectionLogsToggle.tsx deleted file mode 100644 index f44ad84d033..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/components/ConnectionLogsToggle.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useQueryStates } from 'nuqs' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Checkbox, Label } from 'ui' - -import { SEARCH_PARAMS_PARSER } from '../UnifiedLogs.constants' - -export const ConnectionLogsToggle = () => { - const [{ hide_connection_logs }, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) - - return ( - <Accordion type="multiple" defaultValue={['misc']}> - <AccordionItem value="misc" className="border-none"> - <div className="flex items-center gap-2 pr-2"> - <AccordionTrigger className="flex-1 px-2 py-0 hover:no-underline data-[state=closed]:text-muted-foreground data-open:text-foreground focus-within:data-closed:text-foreground hover:data-closed:text-foreground"> - <div className="flex items-center gap-2 truncate py-2"> - <p className="text-sm">Misc</p> - </div> - </AccordionTrigger> - </div> - <AccordionContent> - <div className="p-1"> - <div className="rounded-sm border border-border"> - <div className="group relative flex items-center space-x-2 px-2 py-2 hover:bg-accent/50"> - <Checkbox - id="connection-logs" - checked={!hide_connection_logs} - onCheckedChange={(checked) => setSearch({ hide_connection_logs: !checked })} - /> - <Label - htmlFor="connection-logs" - className="flex w-full cursor-pointer items-center justify-between gap-2 text-[0.8rem] font-normal text-foreground/70 group-hover:text-accent-foreground" - > - Connection logs - </Label> - </div> - </div> - </div> - </AccordionContent> - </AccordionItem> - </Accordion> - ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx index 57f7c28af71..75c46ca5024 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx @@ -1,5 +1,5 @@ import { cn } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' interface HoverCardTimestampProps { date: Date diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.tsx index 2b8d4a8f6af..4596974f61f 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.tsx @@ -6,7 +6,7 @@ import { type FilterBarHandle, type FilterGroup, type FilterProperty, -} from 'ui-patterns' +} from 'ui-patterns/FilterBar' import { isLogsFilterColumnValue, diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index 8a87fa472ca..ae004c1a9c1 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -1,4 +1,3 @@ -import { useFlag } from 'common' import { FlaskConical, Loader2, ScrollText, User2 } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' @@ -16,7 +15,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, singleThemes, - Theme, } from 'ui' import { ButtonTooltip } from '../ui/ButtonTooltip' @@ -42,7 +40,6 @@ export function UserDropdown({ const { theme, setTheme } = useTheme() const appStateSnapshot = useAppStateSnapshot() const profileShowEmailEnabled = useIsFeatureEnabled('profile:show_email') - const timezonePickerEnabled = useFlag('timezonePicker') const { username, avatarUrl, primaryEmail, isLoading } = useProfileNameAndPicture() const { toggleFeaturePreviewModal } = useFeaturePreviewModal() @@ -105,7 +102,9 @@ export function UserDropdown({ </span> )} </div> + <DropdownMenuSeparator /> + <DropdownMenuGroup> <DropdownMenuItem className="flex gap-2 cursor-pointer" asChild> <Link @@ -141,6 +140,7 @@ export function UserDropdown({ </DropdownMenuGroup> </> )} + <DropdownMenuGroup> <DropdownMenuLabel>Theme</DropdownMenuLabel> <DropdownMenuRadioGroup @@ -149,7 +149,7 @@ export function UserDropdown({ setTheme(value) }} > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( <DropdownMenuRadioItem key={theme.value} value={theme.value} @@ -160,14 +160,13 @@ export function UserDropdown({ ))} </DropdownMenuRadioGroup> </DropdownMenuGroup> - {timezonePickerEnabled && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuGroup> - <TimezoneDropdown /> - </DropdownMenuGroup> - </> - )} + + <DropdownMenuSeparator /> + + <DropdownMenuGroup> + <TimezoneDropdown /> + </DropdownMenuGroup> + {showUpgradeCta && ( <> <DropdownMenuSeparator /> diff --git a/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx b/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx index 8844d7d7a06..f48990d77ec 100644 --- a/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx @@ -23,8 +23,9 @@ import { useTrack } from '@/lib/telemetry/track' const AUTO_OPTION_VALUE = '__auto__' export const TimezoneDropdown = () => { - const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone() const track = useTrack() + const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone() + const [open, setOpen] = useState(false) // The "Auto detect" row always advertises the browser's own timezone, even diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx index a1132003e60..24786257a5b 100644 --- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx +++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx @@ -1,7 +1,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { PropsWithChildren } from 'react' -import DefaultLayout from '../DefaultLayout' +import { DefaultLayout } from '../DefaultLayout' import { PageLayout } from '../PageLayout/PageLayout' import AdvisorsLayout from './AdvisorsLayout' import { useIsAdvisorRulesEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx index 760a051d3a0..bbd73078f5c 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { useState } from 'react' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { AppLayoutDropdownError, AppLayoutDropdownWithPopover } from './AppLayoutDropdown' import { BranchBadge } from './BranchBadge' diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx deleted file mode 100644 index 430a08c131d..00000000000 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { AlertCircleIcon } from 'lucide-react' -import Link from 'next/link' -import { Alert, AlertDescription, AlertTitle, Button } from 'ui' - -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useAppStateSnapshot } from '@/state/app-state' - -export const BranchingPlanNotice = () => { - const snap = useAppStateSnapshot() - const { data: selectedOrg } = useSelectedOrganizationQuery() - - return ( - <Alert className="rounded-none px-7 py-6 [&>svg]:top-6 [&>svg]:left-6 border-0 border-t"> - <AlertCircleIcon /> - <AlertTitle>Database branching is only available on the Pro Plan and above</AlertTitle> - <AlertDescription> - Go to your organization's billing settings and upgrade your plan to enable branching for - this project - </AlertDescription> - <AlertDescription> - <Button size="tiny" variant="default" className="mt-4"> - <Link - href={`/org/${selectedOrg?.slug}/billing?panel=subscriptionPlan&source=enableBranchingButton`} - onClick={() => snap.setShowCreateBranchModal(false)} - > - Upgrade to Pro - </Link> - </Button> - </AlertDescription> - </Alert> - ) -} diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPostgresVersionNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPostgresVersionNotice.tsx deleted file mode 100644 index 79b96ac88a1..00000000000 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPostgresVersionNotice.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useParams } from 'common' -import { AlertCircleIcon } from 'lucide-react' -import Link from 'next/link' -import { Alert, AlertDescription, AlertTitle, Button } from 'ui' - -import { useAppStateSnapshot } from '@/state/app-state' - -export const BranchingPostgresVersionNotice = () => { - const { ref } = useParams() - const snap = useAppStateSnapshot() - - return ( - <Alert className="rounded-none px-7 py-6 [&>svg]:top-6 [&>svg]:left-6 border-t-0! border-l-0! border-r-0!"> - <AlertCircleIcon /> - <AlertTitle className="text-base"> - Your project needs to be on Postgres 15 to enable branching - </AlertTitle> - <AlertDescription> - Head over to your project's infrastructure settings to upgrade to the latest version of - Postgres before enabling branching. - </AlertDescription> - <AlertDescription> - <Button size="tiny" variant="default" className="mt-4"> - <Link - href={`/project/${ref}/settings/infrastructure`} - onClick={() => snap.setShowCreateBranchModal(false)} - > - Head to project settings - </Link> - </Button> - </AlertDescription> - </Alert> - ) -} diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx index 6680af603d1..e16b39c433f 100644 --- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx @@ -1,7 +1,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common' import dayjs from 'dayjs' import { useRouter } from 'next/router' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index 3cbf12abc33..a995829b43c 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -3,7 +3,7 @@ import { Boxes } from 'lucide-react' import { useRouter } from 'next/router' import { useState } from 'react' import { Badge, cn } from 'ui' -import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns' +import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { AppLayoutDropdownError, AppLayoutDropdownWithPopover } from './AppLayoutDropdown' import { OrganizationDropdownCommandContent } from './OrganizationDropdownCommandContent' diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx index ce8f6ff14ca..77b0dab49eb 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import type { ComponentProps } from 'react' import { useState } from 'react' import { Button, CommandGroup, CommandItem } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { AppLayoutDropdownTriggerButton } from './AppLayoutDropdown' import { sanitizeRoute } from './ProjectDropdown.utils' diff --git a/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx b/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx index 1d22aaf6203..3538c5e0ce0 100644 --- a/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx +++ b/apps/studio/components/layouts/AuthLayout/Auth.Commands.tsx @@ -38,13 +38,6 @@ export function useAuthGotoCommands(options?: CommandOptions) { route: `/project/${ref}/auth/users`, defaultHidden: true, }, - { - id: 'nav-auth-policies', - name: 'Policies', - value: 'Auth: Policies (RLS)', - route: `/project/${ref}/auth/policies`, - defaultHidden: true, - }, ...(authenticationSignInProviders ? [ { diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.test.ts b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.test.ts index 557684c5246..dd425cce66c 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.test.ts +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.test.ts @@ -141,9 +141,11 @@ describe('generateAuthMenu', () => { const menu = generateAuthMenu({ ...allFeaturesEnabled, ref: 'my-project' }) const users = findItem(menu, 'Users') const oauthApps = findItem(menu, 'OAuth Apps') + const policies = findItem(menu, 'Policies') expect(users?.url).toBe('/project/my-project/auth/users') expect(oauthApps?.url).toBe('/project/my-project/auth/oauth-apps') + expect(policies?.url).toBe('/project/my-project/database/policies') }) it('hides Passkeys when passkeys feature is false', () => { diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts index c27fa348dd8..632611d1292 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts @@ -1,10 +1,14 @@ import { useFlag, useParams } from 'common' +import { ArrowUpRight } from 'lucide-react' +import { createElement } from 'react' import type { ProductMenuGroup } from '@/components/ui/ProductMenu/ProductMenu.types' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { IS_PLATFORM } from '@/lib/constants' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +const ExternalLinkIcon = createElement(ArrowUpRight, { strokeWidth: 1, className: 'h-4 w-4' }) + export interface GenerateAuthMenuOptions { ref?: string isPlatform: boolean @@ -87,9 +91,10 @@ export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuG { name: 'Policies', key: 'policies', - url: `${baseUrl}/policies`, + url: `/project/${ref}/database/policies`, + rightIcon: ExternalLinkIcon, items: [], - shortcutId: SHORTCUT_IDS.NAV_AUTH_POLICIES, + // shortcutId: SHORTCUT_IDS.NAV_AUTH_POLICIES, }, ...(isPlatform ? [ diff --git a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx index 47e1184dcaf..8c3787ae22f 100644 --- a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx @@ -60,6 +60,13 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { route: `/project/${ref}/database/functions`, defaultHidden: true, }, + { + id: 'nav-database-policies', + name: 'Policies', + value: 'Database: Policies (RLS)', + route: `/project/${ref}/database/policies`, + defaultHidden: true, + }, { id: 'nav-database-extensions', name: 'Extensions', @@ -157,6 +164,12 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { route: `/project/${ref}/database/indexes`, icon: () => <Database />, }, + { + id: 'run-view-database-policies', + name: 'View and create RLS policies', + route: `/project/${ref}/database/policies`, + icon: () => <Database />, + }, ...(databaseRoles ? [ { diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index d110a2ceee5..3a24136e309 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -1,53 +1,35 @@ import { useParams } from 'common' -import { ArrowUpRight } from 'lucide-react' -import { - useIsColumnLevelPrivilegesEnabled, - useIsMarketplaceEnabled, -} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useIsColumnLevelPrivilegesEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha' import type { ProductMenuGroup, ProductMenuGroupItem, } from '@/components/ui/ProductMenu/ProductMenu.types' -import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' -const ExternalLinkIcon = <ArrowUpRight strokeWidth={1} className="h-4 w-4" /> - export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const { - databaseReplication: showPgReplicate, - databaseRoles: showRoles, - integrationsWrappers: showWrappers, - } = useIsFeatureEnabled(['database:replication', 'database:roles', 'integrations:wrappers']) + const { databaseReplication: showPgReplicate, databaseRoles: showRoles } = useIsFeatureEnabled([ + 'database:replication', + 'database:roles', + 'integrations:wrappers', + ]) - const { data } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref }) - const pgNetExtensionExists = (data ?? []).some((ext) => ext.name === 'pg_net') const pitrEnabled = addons?.selected_addons.some((addon) => addon.type === 'pitr') ?? false const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled() const enablePgReplicate = useIsETLPrivateAlpha() const getDatabaseURL = (path: string) => `/project/${ref}/database/${path}` - // In the new marketplace revamped page the `category=wrapper` query param has - // changed to `type=wrapper`. So fix this link below based on which version is - // the user viewing. - const isMarketplaceEnabled = useIsMarketplaceEnabled() - const wrappersLinkParamName = isMarketplaceEnabled ? 'type' : 'category' - return [ { title: 'Database Management', @@ -103,8 +85,14 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { ], }, { - title: 'Configuration', + title: 'Access Control', items: [ + { + name: 'Policies', + key: 'policies', + url: getDatabaseURL('policies'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_POLICIES, + }, showRoles && { name: 'Roles', key: 'roles', @@ -117,12 +105,11 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { url: getDatabaseURL('column-privileges'), shortcutId: SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES, }, - { - name: 'Policies', - key: 'policies', - url: `/project/${ref}/auth/policies`, - rightIcon: ExternalLinkIcon, - }, + ].filter(Boolean) as ProductMenuGroupItem[], + }, + { + title: 'Configuration', + items: [ { name: 'Settings', key: 'settings', @@ -154,42 +141,7 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { url: getDatabaseURL('migrations'), shortcutId: SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS, }, - showWrappers && { - name: 'Wrappers', - key: 'wrappers', - url: `/project/${ref}/integrations?${wrappersLinkParamName}=wrapper`, - rightIcon: ExternalLinkIcon, - }, - pgNetExtensionExists && { - name: 'Database Webhooks', - key: 'hooks', - url: `/project/${ref}/integrations/webhooks/overview`, - rightIcon: ExternalLinkIcon, - }, ].filter(Boolean) as ProductMenuGroupItem[], }, - { - title: 'Tools', - items: [ - { - name: 'Security Advisor', - key: 'security-advisor', - url: `/project/${ref}/advisors/security`, - rightIcon: ExternalLinkIcon, - }, - { - name: 'Performance Advisor', - key: 'performance-advisor', - url: `/project/${ref}/advisors/performance`, - rightIcon: ExternalLinkIcon, - }, - { - name: 'Query Performance', - key: 'query-performance', - url: `/project/${ref}/observability/query-performance`, - rightIcon: ExternalLinkIcon, - }, - ], - }, ] } diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx index 375aa6ba03f..c9a41fc8d99 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren } from 'react' import DatabaseLayout from './DatabaseLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' type DatabaseTriggersLayoutProps = PropsWithChildren diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index 5670934aafc..58e3b7b9883 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -126,5 +126,3 @@ export const DefaultLayout = ({ </SidebarProvider> ) } - -export default DefaultLayout diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index c72a5408135..f32b32e2cb7 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -25,7 +25,6 @@ import { PopoverTrigger, Separator, } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { PageHeader, @@ -37,6 +36,7 @@ import { PageHeaderSummary, PageHeaderTitle, } from 'ui-patterns/PageHeader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ProjectLayout } from '../ProjectLayout' import EdgeFunctionsLayout from './EdgeFunctionsLayout' @@ -44,7 +44,7 @@ import { EdgeFunctionTesterSheet } from '@/components/interfaces/Functions/EdgeF import { useFunctionsDetailShortcuts } from '@/components/interfaces/Functions/useFunctionsDetailShortcuts' import CopyButton from '@/components/ui/CopyButton' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query' diff --git a/apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx b/apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx index 4496bc097f3..a33f5e20844 100644 --- a/apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx +++ b/apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx @@ -1,12 +1,12 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { Menu, Separator } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { getCategoryParamFromAsPath, getIntegrationsPageFromPathname } from './Integrations.utils' import { generateIntegrationsMenu } from './IntegrationsMenu.utils' import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ProductMenu } from '@/components/ui/ProductMenu' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx index 9d2af4613f3..993dda2f7db 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren } from 'react' import { ProjectLayout } from '../ProjectLayout' import { LogsSidebarMenuV2 } from './LogsSidebarMenuV2' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index 6ac286ae309..bb2596db57f 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -1,17 +1,9 @@ -import { IS_PLATFORM, useFlag, useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { ChevronRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import React, { useState } from 'react' -import { - Badge, - Button, - cn, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - Separator, -} from 'ui' +import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, Separator } from 'ui' import { InnerSideBarEmptyPanel, InnerSideBarFilters, @@ -21,15 +13,12 @@ import { import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { FeaturePreviewSidebarPanel } from '../../ui/FeaturePreviewSidebarPanel' -import { - useFeaturePreviewModal, - useUnifiedLogsPreview, -} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha' import { LOG_DRAIN_TYPES } from '@/components/interfaces/LogDrains/LogDrains.constants' -import SavedQueriesItem from '@/components/interfaces/Settings/Logs/Logs.SavedQueriesItem' +import { SavedQueriesItem } from '@/components/interfaces/Settings/Logs/Logs.SavedQueriesItem' import { LogsSidebarItem } from '@/components/interfaces/Settings/Logs/SidebarV2/SidebarItem' import { UnifiedLogsBanner } from '@/components/interfaces/UnifiedLogs/UnifiedLogsBanner' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useContentQuery } from '@/data/content/content-query' import { useReplicationSourcesQuery } from '@/data/replication/sources-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' @@ -64,10 +53,6 @@ export function LogsSidebarMenuV2() { const router = useRouter() const { ref } = useParams() as { ref: string } - const unifiedLogsFlagEnabled = useFlag('unifiedLogs') - const { selectFeaturePreview } = useFeaturePreviewModal() - const { enable: enableUnifiedLogs, isEligible: isUnifiedLogsEligible } = useUnifiedLogsPreview() - const [searchText, setSearchText] = useState('') const { @@ -225,32 +210,7 @@ export function LogsSidebarMenuV2() { return ( <div className="pb-4 relative"> - {IS_PLATFORM && !unifiedLogsFlagEnabled && ( - <FeaturePreviewSidebarPanel - className="mx-4 mt-4" - illustration={<Badge variant="default">Coming soon</Badge>} - title="New logs" - description="Get early access" - actions={ - <Link href="https://forms.supabase.com/unified-logs-signup" target="_blank"> - <Button variant="default" size="tiny"> - Early access - </Button> - </Link> - } - /> - )} - {isUnifiedLogsEligible && ( - <UnifiedLogsBanner - variant="promo" - className="mx-4 mt-4" - onEnable={() => { - enableUnifiedLogs() - router.push(`/project/${ref}/logs`) - }} - onMoreInfo={() => selectFeaturePreview('supabase-ui-preview-unified-logs')} - /> - )} + <UnifiedLogsBanner /> <div className={cn( @@ -265,15 +225,18 @@ export function LogsSidebarMenuV2() { aria-labelledby="Search collections" value={searchText} onChange={(e) => setSearchText(e.target.value)} - ></InnerSideBarFilterSearchInput> + /> </InnerSideBarFilters> - <Button + <ButtonTooltip + asChild variant="default" icon={<Plus className="text-foreground" />} className="w-[26px]" - onClick={() => router.push(`/project/${ref}/logs/explorer`)} - /> + tooltip={{ content: { text: 'New query', side: 'bottom' } }} + > + <Link href={`/project/${ref}/logs/explorer`} /> + </ButtonTooltip> </div> {templatesEnabled && ( <div className="px-2"> diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx index 4d22659cd19..a01143df780 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx @@ -17,7 +17,7 @@ import { PopoverSeparator, TextArea, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { convertB64toBlob, diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx index 9cf129270c9..c5dea166e95 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx @@ -6,7 +6,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ReactNode, useMemo } from 'react' import { Badge, cn } from 'ui' -import { CommandMenuTriggerInput } from 'ui-patterns' +import { CommandMenuTriggerInput } from 'ui-patterns/CommandMenu' import { BreadcrumbsView } from './BreadcrumbsView' import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown' diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/MobileNavigationBar.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/MobileNavigationBar.tsx index b9b7e606cb0..82db548a6b5 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/MobileNavigationBar.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/MobileNavigationBar.tsx @@ -3,7 +3,7 @@ import { ChevronLeft, Menu } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { Button, cn } from 'ui' -import { MobileSheetNav } from 'ui-patterns' +import { MobileSheetNav } from 'ui-patterns/MobileSheetNav' import { HomeIcon } from '../LayoutHeader/HomeIcon' import { useMobileSheet } from './MobileSheetContext' diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorSheetTabTrigger.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorSheetTabTrigger.tsx index 43382c07717..6453a4eb5c8 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorSheetTabTrigger.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/ProjectBranchSelectorSheetTabTrigger.tsx @@ -45,9 +45,9 @@ export function ProjectBranchSelectorSheetTabTrigger({ > <path d="M5.49365 31L0.493652 0V62L5.49365 31Z" - className="fill-[hsl(var(--background-dash-sidebar))] group-data-[state=active]:fill-[hsl(var(--background-surface-200))]" + className="fill-[var(--background-dash-sidebar)] group-data-[state=active]:fill-[var(--background-surface-200)]" /> - <path d="M0.493652 0L5.49365 31L0.493652 62" stroke="hsl(var(--border-default))" /> + <path d="M0.493652 0L5.49365 31L0.493652 62" stroke="var(--border-default)" /> </svg> )} </TabsTrigger_Shadcn_> diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/StudioMobileSheetNav.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/StudioMobileSheetNav.tsx index 60350900db2..ad3ae93e090 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/StudioMobileSheetNav.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/StudioMobileSheetNav.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' -import { CommandWrapper, MobileSheetNav } from 'ui-patterns' +import { CommandWrapper } from 'ui-patterns/CommandMenu' +import { MobileSheetNav } from 'ui-patterns/MobileSheetNav' import { SIDEBAR_KEYS, diff --git a/apps/studio/components/layouts/ObservabilityLayout/Observability.Commands.tsx b/apps/studio/components/layouts/ObservabilityLayout/Observability.Commands.tsx deleted file mode 100644 index ebfbd4bb87a..00000000000 --- a/apps/studio/components/layouts/ObservabilityLayout/Observability.Commands.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useParams } from 'common' -import { useMemo } from 'react' -import type { CommandOptions, ICommand } from 'ui-patterns/CommandMenu' -import { orderSectionFirst, useQuery, useRegisterCommands } from 'ui-patterns/CommandMenu' - -import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' -import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' - -const QUERY_PERFORMANCE_COMMAND_ID = 'nav-reports-query-performance' - -export function useReportsGotoCommands(options?: CommandOptions) { - let { ref } = useParams() - ref ||= '_' - - const { reportsAll } = useIsFeatureEnabled(['reports:all']) - - const commandQuery = useQuery()?.toLowerCase() ?? '' - const prioritizeQueryPerformance = commandQuery.includes('query') - - const orderQueryPerformanceCommand = useMemo(() => { - if (!prioritizeQueryPerformance) return undefined - - return (existingCommands: ICommand[], incomingCommands: ICommand[]) => { - const filteredExisting = existingCommands.filter( - (command) => command.id !== QUERY_PERFORMANCE_COMMAND_ID - ) - - return [...incomingCommands, ...filteredExisting] - } - }, [prioritizeQueryPerformance]) - - const orderNavigateSection = useMemo<CommandOptions['orderSection'] | undefined>(() => { - return prioritizeQueryPerformance ? orderSectionFirst : options?.orderSection - }, [options?.orderSection, prioritizeQueryPerformance]) - - useRegisterCommands( - COMMAND_MENU_SECTIONS.NAVIGATE, - reportsAll - ? [ - { - id: 'nav-reports', - name: 'Reports', - route: `/project/${ref}/observability`, - defaultHidden: true, - }, - { - id: 'nav-reports-api', - name: 'API Reports', - route: `/project/${ref}/observability/api-overview`, - defaultHidden: true, - }, - { - id: 'nav-reports-storage', - name: 'Storage Reports', - route: `/project/${ref}/observability/storage`, - defaultHidden: true, - }, - { - id: 'nav-reports-database', - name: 'Database Reports', - route: `/project/${ref}/observability/database`, - defaultHidden: true, - }, - ] - : [], - { - ...options, - orderSection: orderNavigateSection, - deps: [ref, orderNavigateSection, ...(options?.deps ?? [])], - } - ) - - useRegisterCommands( - COMMAND_MENU_SECTIONS.NAVIGATE, - reportsAll - ? [ - { - id: QUERY_PERFORMANCE_COMMAND_ID, - name: 'Query Performance Reports', - route: `/project/${ref}/observability/query-performance`, - defaultHidden: true, - }, - ] - : [], - { - ...options, - orderCommands: orderQueryPerformanceCommand ?? options?.orderCommands, - orderSection: orderNavigateSection, - deps: [ref, orderQueryPerformanceCommand, orderNavigateSection, ...(options?.deps ?? [])], - } - ) -} diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx index e99fad30b63..052ffd9a585 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx @@ -6,8 +6,8 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Menu } from 'ui' -import { InnerSideBarEmptyPanel } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { InnerSideBarEmptyPanel } from 'ui-patterns/InnerSideMenu' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { generateObservabilityMenuItems } from './ObservabilityMenu.utils' diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx index 244b489bdba..51ca726cb0b 100644 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx @@ -2,12 +2,12 @@ import { IS_PLATFORM, useFeatureFlags, useParams } from 'common' import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' import { Menu, Separator } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations' import { ProjectLayout } from '@/components/layouts/ProjectLayout' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ProductMenu } from '@/components/ui/ProductMenu' import { useMarketplaceCategoriesQuery } from '@/data/marketplace/integration-categories-query' import { useMarketplaceIntegrationsQuery } from '@/data/marketplace/integrations-query' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx index 84f627e8ac8..b5ac80bea02 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx @@ -6,7 +6,7 @@ import { ChevronLeft } from 'lucide-react' import { useRouter } from 'next/router' import React, { useMemo } from 'react' import { Button, cn, Separator, SidebarGroup, SidebarMenu } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { resolveSectionDisplay } from './MobileMenuContent.utils' import { getProductMenuComponent } from './mobileProductMenuRegistry' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx index c9856d25270..b753d3b076e 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx @@ -5,7 +5,7 @@ import { Blocks, Boxes, ChartArea, ChevronLeft, Receipt, Settings, Users } from import { useRouter } from 'next/router' import React, { useMemo } from 'react' import { Button, cn, SidebarGroup, SidebarMenu } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { getOrgMenuComponent } from './mobileOrgMenuRegistry' import type { OrgNavItem } from './OrgMenuContent.utils' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index e059682c89d..7f3b5b486d2 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -5,7 +5,7 @@ import { parseAsString, useQueryState } from 'nuqs' import { useEffect, useEffectEvent, type PropsWithChildren } from 'react' import { getSupportLinkQueryParams } from '@/components/ui/HelpPanel/HelpPanel.utils' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx index f4a5ebbdf90..25dcf86ec2b 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx @@ -10,7 +10,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index d97a5a22165..326d9bbe68e 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -12,8 +12,8 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { PauseDisabledState } from './PauseDisabledState' import { ResumeProjectButton } from '@/components/interfaces/Project/ResumeProjectButton' diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx index 1365e8ef4de..d4ecdd8c26b 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react' import { DatabaseSelectorStateContextProvider } from '@/state/database-selector' import { RoleImpersonationStateContextProvider } from '@/state/role-impersonation-state' +import { SqlEditorSaveCoordinatorProvider } from '@/state/sql-editor/sql-editor-save-coordinator' import { TableEditorStateContextProvider } from '@/state/table-editor' import { TabsStateContextProvider } from '@/state/tabs' @@ -18,7 +19,7 @@ export const ProjectContextProvider = ({ <TabsStateContextProvider key={`tabs-state-${projectRef}`}> <DatabaseSelectorStateContextProvider key={`database-selector-state-${projectRef}`}> <RoleImpersonationStateContextProvider key={`role-impersonation-state-${projectRef}`}> - {children} + <SqlEditorSaveCoordinatorProvider>{children}</SqlEditorSaveCoordinatorProvider> </RoleImpersonationStateContextProvider> </DatabaseSelectorStateContextProvider> </TabsStateContextProvider> diff --git a/apps/studio/components/layouts/ProjectLayout/RestorePaidPlanProjectNotice.tsx b/apps/studio/components/layouts/ProjectLayout/RestorePaidPlanProjectNotice.tsx deleted file mode 100644 index 129d171a004..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/RestorePaidPlanProjectNotice.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ExternalLink } from 'lucide-react' -import { Alert, AlertDescription, AlertTitle, Button, WarningIcon } from 'ui' - -import { DOCS_URL } from '@/lib/constants' - -export const RestorePaidPlanProjectNotice = () => { - return ( - <Alert> - <WarningIcon /> - <AlertTitle>Project will count towards compute usage once restored</AlertTitle> - <AlertDescription> - For every hour your instance is active, we will bill you based on the compute size of your - project. - </AlertDescription> - <AlertDescription className="mt-3"> - <Button asChild variant="default" icon={<ExternalLink />}> - <a - href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`} - target="_blank" - rel="noreferrer" - > - More information - </a> - </Button> - </AlertDescription> - </Alert> - ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx index 8fca42fbcc8..6a2ebf1f56b 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -193,7 +193,6 @@ vi.mock('@/components/ui/BannerStack/Banners/BannerUnifiedLogs', () => ({ vi.mock('@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({ useUnifiedLogsPreview: () => ({ isEnabled: false, - isEligible: false, isLoading: false, enable: () => {}, disable: () => {}, @@ -392,7 +391,9 @@ describe('FREE_MICRO_UPGRADE banner', () => { await waitFor(() => { expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner') }) - expect(mockAddBanner).not.toHaveBeenCalled() + expect(mockAddBanner).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'free-micro-upgrade-banner' }) + ) }) it('calls dismissBanner when compute warnings are cleared', async () => { @@ -410,7 +411,9 @@ describe('FREE_MICRO_UPGRADE banner', () => { await waitFor(() => { expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner') }) - expect(mockAddBanner).not.toHaveBeenCalled() + expect(mockAddBanner).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'free-micro-upgrade-banner' }) + ) }) it('calls dismissBanner when project is not nano compute', async () => { @@ -421,6 +424,8 @@ describe('FREE_MICRO_UPGRADE banner', () => { await waitFor(() => { expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner') }) - expect(mockAddBanner).not.toHaveBeenCalled() + expect(mockAddBanner).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'free-micro-upgrade-banner' }) + ) }) }) diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 6d811bdb278..c083583c8fb 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { XIcon } from 'lucide-react' import Head from 'next/head' @@ -41,7 +41,6 @@ import { RestoreFailedState } from './RestoreFailedState' import { RestoringState } from './RestoringState' import { UnhealthyState } from './UnhealthyState' import { UpgradingState } from './UpgradingState' -import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal' import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade' @@ -143,7 +142,7 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec const { data: resourceWarnings } = useResourceWarningsQuery({ slug: selectedOrganization?.slug, }) - const projectResourceWarnings = resourceWarnings?.find( + const projectResourceWarnings = (Array.isArray(resourceWarnings) ? resourceWarnings : []).find( (w) => w.project === selectedProject?.ref ) const isComputeNearExhaustion = @@ -161,7 +160,6 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec LOCAL_STORAGE_KEYS.UNIFIED_LOGS_BANNER_DISMISSED, false ) - const { isEligible: showUnifiedLogsBanner } = useUnifiedLogsPreview() const [isProjectIntegrationBannerDismissed, setIsProjectIntegrationBannerDismissed] = useLocalStorageQuery( getProjectIntegrationBannerDismissKey({ @@ -241,7 +239,7 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec useEffect(() => { if (!selectedProject?.ref) return - if (showUnifiedLogsBanner && !isUnifiedLogsBannerDismissed) { + if (IS_PLATFORM && !isUnifiedLogsBannerDismissed) { addBanner({ id: BANNER_ID.UNIFIED_LOGS, isDismissed: false, @@ -251,13 +249,7 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec } else { dismissBanner(BANNER_ID.UNIFIED_LOGS) } - }, [ - selectedProject?.ref, - showUnifiedLogsBanner, - isUnifiedLogsBannerDismissed, - addBanner, - dismissBanner, - ]) + }, [selectedProject?.ref, isUnifiedLogsBannerDismissed, addBanner, dismissBanner]) useLayoutEffect(() => { const unregister = registerOpenMenu(() => { diff --git a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.test.tsx b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.test.tsx index 6892203fd98..cdd4b125676 100644 --- a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.test.tsx +++ b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.test.tsx @@ -205,7 +205,7 @@ describe('ProjectNeedsSecuring', () => { fireEvent.click(screen.getByText('invoices')) expect(mockRouterPush).toHaveBeenCalledWith( - '/project/project-ref/auth/policies?schema=public&search=invoices' + '/project/project-ref/database/policies?schema=public&search=invoices' ) }) diff --git a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts index 42d8dc33170..5684676a8ea 100644 --- a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts +++ b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.test.ts @@ -63,7 +63,7 @@ describe('ProjectNeedsSecuring.utils: buildSecurityPromptMarkdown', () => { describe('ProjectNeedsSecuring.utils: getTablePoliciesHref', () => { it('builds the policies href with plain values', () => { expect(getTablePoliciesHref('abc', 'public', 'invoices')).toBe( - '/project/abc/auth/policies?schema=public&search=invoices' + '/project/abc/database/policies?schema=public&search=invoices' ) }) @@ -90,7 +90,7 @@ describe('ProjectNeedsSecuring.utils: getTablePoliciesHref', () => { it('falls back to empty strings for undefined inputs', () => { expect(getTablePoliciesHref(undefined, undefined, undefined)).toBe( - '/project//auth/policies?schema=&search=' + '/project//database/policies?schema=&search=' ) }) }) diff --git a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts index 86980be37e3..518b5ae6736 100644 --- a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts +++ b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils.ts @@ -11,7 +11,7 @@ export const getTablePoliciesHref = ( schema: string | undefined, name: string | undefined ): string => { - return `/project/${projectRef ?? ''}/auth/policies?schema=${encodeURIComponent( + return `/project/${projectRef ?? ''}/database/policies?schema=${encodeURIComponent( schema ?? '' )}&search=${encodeURIComponent(name ?? '')}` } diff --git a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx index 0c61b94ed1b..2b897d78724 100644 --- a/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx +++ b/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx @@ -46,7 +46,7 @@ import { } from './ProjectNeedsSecuring.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { createNavigationHandler } from '@/lib/navigation' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx index 1b7258539a0..9a992684534 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx @@ -58,7 +58,7 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { name: 'Auth Settings', route: authenticationSignInProviders ? `/project/${ref}/auth/providers` - : `/project/${ref}/auth/policies`, + : `/project/${ref}/database/policies`, defaultHidden: true, }, ...(platformWebhooksEnabled diff --git a/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx b/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx index c07c63d9345..e2be2602639 100644 --- a/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx +++ b/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx @@ -1,14 +1,14 @@ import { Realtime } from 'icons' -import { ExternalLink } from 'lucide-react' import Head from 'next/head' import { useRouter } from 'next/router' import type { PropsWithChildren } from 'react' -import { Button } from 'ui' import { ProjectLayout } from '../ProjectLayout' import { generateRealtimeMenu } from './RealtimeMenu.utils' +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { ProductMenu } from '@/components/ui/ProductMenu' import { ProductMenuShortcuts } from '@/components/ui/ProductMenu/ProductMenuShortcuts' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { withAuth } from '@/hooks/misc/withAuth' @@ -31,11 +31,12 @@ export interface RealtimeLayoutProps { export const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutProps>) => { const { data: project } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const router = useRouter() const page = router.pathname.split('/')[4] const menu = generateRealtimeMenu(project) - if (!!project?.high_availability) { + if (isHighAvailability) { return ( <> <Head> @@ -46,26 +47,11 @@ export const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLa id="panel-project-content" className="h-full w-full xl:min-w-[600px] flex items-center justify-center overflow-y-auto overflow-x-hidden bg-dash-sidebar p-6 @container" > - <div className="flex max-w-md flex-col items-center gap-y-5 text-center"> - <div className="flex h-12 w-12 items-center justify-center rounded-lg border border-strong bg-surface-100 text-foreground-light"> - <Realtime size={22} strokeWidth={1.5} /> - </div> - <div className="flex flex-col gap-y-2"> - <h3 className="text-base text-foreground"> - Realtime is not available for High Availability projects - </h3> - <p className="text-sm text-foreground-light"> - Realtime is not currently available for projects with High Availability. Reach out - to our support team if you're interested in using this feature with your High - Availability project. - </p> - </div> - <Button asChild variant="default" icon={<ExternalLink />}> - <a href="https://supabase.com/support" target="_blank" rel="noopener noreferrer"> - Contact support - </a> - </Button> - </div> + <HighAvailabilityDisabledEmptyState + icon={<Realtime size={22} strokeWidth={1.5} />} + title="Realtime unavailable on High Availability projects" + description="We're working to bring realtime to High Availability projects. Contact support if this is blocking your work." + /> </main> </> ) diff --git a/apps/studio/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty.tsx b/apps/studio/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty.tsx index f448e6fe5c6..ab5610689bb 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty.tsx @@ -1,6 +1,6 @@ import { Pointer } from 'lucide-react' import { TreeViewItem } from 'ui' -import { InnerSideBarEmptyPanel } from 'ui-patterns' +import { InnerSideBarEmptyPanel } from 'ui-patterns/InnerSideMenu' export const EmptyPrivateQueriesPanel = () => ( <InnerSideBarEmptyPanel diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index f3ab98967a3..2dc29b01f0d 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -29,7 +29,7 @@ import { useLocalStorage } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' import { getAppStateSnapshot } from '@/state/app-state' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export const SQLEditorMenu = () => { const router = useRouter() @@ -135,14 +135,20 @@ export const SQLEditorMenu = () => { </InnerSideBarFilterSearchInput> </InnerSideBarFilters> <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - data-testid="sql-editor-new-query-button" - variant="default" - icon={<Plus className="text-foreground" />} - className="w-[26px]" - /> - </DropdownMenuTrigger> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + data-testid="sql-editor-new-query-button" + variant="default" + icon={<Plus className="text-foreground" />} + className="w-[26px]" + aria-label="Create a new query" + /> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">Create a new query</TooltipContent> + </Tooltip> <DropdownMenuContent align="end" side="bottom" className="w-48"> <DropdownMenuItem className="gap-x-2" onClick={() => handleNewQuery()}> <FilePlus size={14} /> diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/DeleteSnippetsModal.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/DeleteSnippetsModal.tsx index b4444814c2c..53a5cc3d01b 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/DeleteSnippetsModal.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/DeleteSnippetsModal.tsx @@ -5,7 +5,7 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' import { Snippet } from '@/data/content/sql-folders-query' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' export const DeleteSnippetsModal = ({ diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ReferenceSnippetsSection.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ReferenceSnippetsSection.tsx index fb552dc36d2..ab7eed3532b 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ReferenceSnippetsSection.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ReferenceSnippetsSection.tsx @@ -5,8 +5,8 @@ import { InnerSideMenuCollapsible, InnerSideMenuCollapsibleContent, InnerSideMenuCollapsibleTrigger, -} from 'ui-patterns' -import { InnerSideMenuDataItem } from 'ui-patterns/InnerSideMenu' + InnerSideMenuDataItem, +} from 'ui-patterns/InnerSideMenu' import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants' import { useLocalStorage } from '@/hooks/misc/useLocalStorage' diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 35c7021f9cb..db4ade77062 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -5,14 +5,14 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { TreeView } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { InnerSideBarEmptyPanel, InnerSideMenuCollapsible, InnerSideMenuCollapsibleContent, InnerSideMenuCollapsibleTrigger, InnerSideMenuSeparator, -} from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +} from 'ui-patterns/InnerSideMenu' import { DeleteSnippetsModal } from './DeleteSnippetsModal' import { ReferenceSnippetsSection } from './ReferenceSnippetsSection' @@ -34,11 +34,12 @@ import { useContentCountQuery } from '@/data/content/content-count-query' import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' import { useSQLSnippetFoldersDeleteMutation } from '@/data/content/sql-folders-delete-mutation' import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from '@/data/content/sql-folders-query' -import { useSqlSnippetsQuery, type SqlSnippet } from '@/data/content/sql-snippets-query' +import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query' import { useLocalStorage } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' -import { useSnippetFolders, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { isNewFolder } from '@/state/sql-editor/sql-editor-lifecycle' +import { useSnippetFolders, useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' interface SQLEditorNavProps { @@ -204,7 +205,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) { - snippets.push(snippet as SqlSnippet) + snippets.push(snippet) } return ( @@ -255,7 +256,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) { - snippets.push(snippet as SqlSnippet) + snippets.push(snippet) } return ( @@ -420,7 +421,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { useEffect(() => { if (projectRef && privateSnippetsPages?.pages) { privateSnippetsPages.pages.forEach((page) => { - page.contents?.forEach((snippet: Snippet) => { + page.contents?.forEach((snippet) => { snapV2.addSnippet({ projectRef, snippet }) }) page.folders?.forEach((folder) => snapV2.addFolder({ projectRef, folder })) @@ -719,10 +720,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} onEditSave={(name: string) => { // [Joshen] Inline editing only for folders for now - if (name.length === 0 && element.id === 'new-folder') { - snapV2.removeFolder(element.id as string) + const folderId = element.id as string + if (name.length === 0 && isNewFolder(snapV2.folders[folderId]?.status)) { + snapV2.removeFolder(folderId) } else if (name.length > 0) { - snapV2.saveFolder({ id: element.id as string, name }) + snapV2.saveFolder({ id: folderId, name }) } }} hasNextPage={hasNextPage} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 2235f37d60e..828f99b7687 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -29,14 +29,19 @@ import { } from 'ui' import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' -import { getContentById } from '@/data/content/content-id-query' +import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query' import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query' import { Snippet } from '@/data/content/sql-folders-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { + isFolderEditing, + isFolderSaving, + type FolderStatus, +} from '@/state/sql-editor/sql-editor-lifecycle' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' interface SQLEditorTreeViewItemProps extends Omit< ComponentProps<typeof TreeViewItem>, @@ -44,7 +49,7 @@ interface SQLEditorTreeViewItemProps extends Omit< > { element: any isMultiSelected?: boolean - status?: 'editing' | 'saving' | 'idle' + status?: FolderStatus getNodeProps: () => any onSelectCreate?: () => void onSelectDelete?: () => void @@ -105,8 +110,8 @@ export const SQLEditorTreeViewItem = ({ const isSharedSnippet = element.metadata.visibility === 'project' const isFavorite = element.metadata.favorite - const isEditing = status === 'editing' - const isSaving = status === 'saving' + const isEditing = isFolderEditing(status) + const isSaving = isFolderSaving(status) const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, @@ -188,12 +193,8 @@ export const SQLEditorTreeViewItem = ({ // opened have no content loaded yet, so fetch it before toggling. const storeSnippet = snapV2.snippets[snippetId] if (!storeSnippet?.snippet.content) { - const data = await getContentById({ projectRef, id: snippetId }) - // getContentById returns a union content; narrow to the SQL variant (the - // only kind the SQL editor loads) via its discriminating field. - if ('unchecked_sql' in data.content) { - snapV2.setSnippet(projectRef, { ...data, content: data.content }) - } + const snippet = await getSqlSnippetById({ projectRef, id: snippetId }) + snapV2.setSnippet(projectRef, snippet) } if (isFavorite) snapV2.removeFavorite(snippetId) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ShareSnippetModal.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ShareSnippetModal.tsx index 99d2c3e09ff..f3cd44d3ef6 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ShareSnippetModal.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ShareSnippetModal.tsx @@ -6,7 +6,7 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { getContentById } from '@/data/content/content-id-query' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' import { Snippet } from '@/data/content/sql-folders-query' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import type { SqlSnippets } from '@/types' export const ShareSnippetModal = ({ diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/UnshareSnippetModal.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/UnshareSnippetModal.tsx index dfeb09ed800..ad35294d4c2 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/UnshareSnippetModal.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/UnshareSnippetModal.tsx @@ -6,7 +6,7 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { getContentById } from '@/data/content/content-id-query' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' import { Snippet } from '@/data/content/sql-folders-query' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import type { SqlSnippets } from '@/types' export const UnshareSnippetModal = ({ diff --git a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx index 0c95f7a8691..e1b5d4ec6e9 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx @@ -26,7 +26,8 @@ import { import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' import { orderCommandSectionsByPriority } from '@/components/interfaces/App/CommandMenu/ordering' -import { useSqlSnippetsQuery, type SqlSnippet } from '@/data/content/sql-snippets-query' +import { type SnippetWithContent } from '@/data/content/sql-folders-query' +import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query' import { useInfiniteTablesQuery, usePrefetchTables, @@ -185,7 +186,7 @@ function SnippetSelector({ canCreateNew, }: { projectRef: string | undefined - snippets: Array<SqlSnippet> | undefined + snippets: Array<SnippetWithContent> | undefined canCreateNew: boolean }) { const router = useRouter() @@ -244,7 +245,7 @@ function SnippetSelector({ ) } -function snippetValue(snippet: SqlSnippet) { +function snippetValue(snippet: SnippetWithContent) { if (snippet.type !== 'sql') return '' return escapeAttributeSelector( `${snippet.id}-${snippet.name}-${snippet?.content?.unchecked_sql.slice(0, 30)}` diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index c6a13814147..82b246ec302 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -365,7 +365,7 @@ export const EntityListItem = ({ <DropdownMenuItem key="view-policies" className="space-x-2" asChild> <Link key="view-policies" - href={`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`} + href={`/project/${projectRef}/database/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`} > <Lock size={12} className="shrink-0" /> <span>View policies</span> diff --git a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx index c20db337c0e..34df92f17d4 100644 --- a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx @@ -30,7 +30,7 @@ import { tableEditorKeys } from '@/data/table-editor/keys' import { getTableEditor, type TableEditorData } from '@/data/table-editor/table-editor-query' import { isTableLike } from '@/data/table-editor/table-editor-types' import { fetchAllTableRows } from '@/data/table-rows/table-rows-query' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { DOCS_URL } from '@/lib/constants' import type { RoleImpersonationState } from '@/lib/role-impersonation' diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index 882aa8ad5e8..f9b7fb32cef 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -7,7 +7,7 @@ import { SaveQueueActionBar } from '@/components/grid/components/footer/operatio import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' import { BannerTableEditorQueueOperations } from '@/components/ui/BannerStack/Banners/BannerTableEditorQueueOperations' import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 75543eb2207..43c120841e7 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -3,7 +3,17 @@ import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { Filter, Plus } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' -import { Button, Checkbox, Label, Popover, PopoverContent, PopoverTrigger } from 'ui' +import { + Button, + Checkbox, + Label, + Popover, + PopoverContent, + PopoverTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' import { InnerSideBarEmptyPanel, InnerSideBarFilters, @@ -23,7 +33,7 @@ import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher import { EditorMenuListSkeleton } from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query' @@ -240,11 +250,17 @@ export const TableEditorMenu = () => { </InnerSideBarFilterSearchInput> <Popover> <PopoverTrigger asChild> - <Button - variant={visibleTypes.length !== 5 ? 'default' : 'dashed'} - className="h-[32px] md:h-[28px] px-1.5" - icon={<Filter />} - /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant={visibleTypes.length !== 5 ? 'default' : 'dashed'} + className="h-[32px] md:h-[28px] px-1.5" + icon={<Filter />} + aria-label="Filter" + /> + </TooltipTrigger> + <TooltipContent side="bottom">Filter</TooltipContent> + </Tooltip> </PopoverTrigger> <PopoverContent className="p-0 w-56" side="bottom" align="center"> <div className="px-3 pt-3 pb-2 flex flex-col gap-y-2"> diff --git a/apps/studio/components/layouts/Tabs/MigrateToPostgres.tsx b/apps/studio/components/layouts/Tabs/MigrateToPostgres.tsx deleted file mode 100644 index 8bd139d1f17..00000000000 --- a/apps/studio/components/layouts/Tabs/MigrateToPostgres.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { motion } from 'framer-motion' -import { BaggageClaim, BookOpen } from 'lucide-react' -import { Button } from 'ui' - -// not in use yet -export const MigrateToPostgres = () => ( - <motion.div - className="w-full rounded-lg overflow-hidden relative border border-muted" - variants={{ - hidden: { opacity: 0, scale: 0.96, y: 15 }, - show: { - opacity: 1, - scale: 1, - y: 0, - transition: { - type: 'spring', - stiffness: 600, - damping: 50, - staggerChildren: 0.08, - delayChildren: 0.03, - }, - }, - }} - > - <div className="absolute inset-0 bg-linear-to-r from-background-surface-200 via-background-surface-200 to-transparent"></div> - <div className="relative z-10 px-5 py-4 h-full flex gap-5 items-center justify-between"> - <div className="flex flex-col gap-3"> - <div className="flex gap-4"> - <div className="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-surface-300 border"> - <BaggageClaim size={14} className="text-foreground-light" strokeWidth={1.5} /> - </div> - <div className="flex flex-col gap-1"> - <h2 className="text-base text-foreground">Migrate your Database to Postgres</h2> - <p className="text-xs text-foreground-light max-w-md"> - Already have a database? Migrate any database to Supabase to access backups, RESTful - auto APIs, Authentication and more. - </p> - </div> - </div> - </div> - <Button variant="default" className="rounded-full" iconRight={<BookOpen />}> - Migrate to Postgres - </Button> - </div> - </motion.div> -) diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index 3e1582745f1..eae4578697f 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react' import { Entity } from '@/data/entity-types/entity-types-infinite-query' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { createTabId, editorEntityTypes, useTabsStateSnapshot } from '@/state/tabs' export function useTableEditorTabsCleanUp() { diff --git a/apps/studio/components/to-be-cleaned/KeyMap.tsx b/apps/studio/components/to-be-cleaned/KeyMap.tsx deleted file mode 100644 index a68221cabb2..00000000000 --- a/apps/studio/components/to-be-cleaned/KeyMap.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Render keyMap - * - * @param {String} keyMap ex: Command+Enter ; separate key using + - */ - -const customKeyChars: any = { - command: '⌘', - control: '⌃', - option: '⌥', - enter: '↵', -} - -const KeyMap = ({ keyMap }: any) => { - if (!keyMap) return null - - const keys = keyMap.split('+') - const keymapRender = keys.map((key: any) => { - let keyChar = key - if (key.toLowerCase() in customKeyChars) keyChar = customKeyChars[key.toLowerCase()] - return ( - <span key={key} className={`btn-keymap ${keyChar.length > 1 ? 'px-2' : ''}`}> - {keyChar} - </span> - ) - }) - return <p className="inline-flex space-x-1.5">{keymapRender}</p> -} - -export default KeyMap diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 6db42ca1442..62ec0219eea 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -8,9 +8,9 @@ import { Eraser, Pencil, X } from 'lucide-react' import { useRouter } from 'next/router' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, cn, KeyboardShortcut } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' -import AlertError from '../AlertError' +import { AlertError } from '../AlertError' import { ButtonTooltip } from '../ButtonTooltip' import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary' import { ASSISTANT_ERRORS } from './AiAssistant.constants' @@ -54,7 +54,7 @@ import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-ass import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' interface AIAssistantProps { initialMessages?: MessageType[] | undefined @@ -414,7 +414,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { }, ]} > - <div className={cn('flex flex-col h-full w-full md:h-full max-h-dvh', className)}> + <div className={cn('flex bg-card flex-col h-full w-full md:h-full max-h-dvh', className)}> <AIAssistantHeader isChatLoading={isChatLoading} onNewChat={snap.newChat} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index f399e554541..801f35fb939 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -127,7 +127,7 @@ export const getContextualInvalidationKeys = ({ ( { 'auth/users': [authKeys.usersInfinite(ref)], - 'auth/policies': [databasePoliciesKeys.list(ref)], + 'database/policies': [databasePoliciesKeys.list(ref)], 'database/functions': [databaseKeys.databaseFunctions(ref)], 'database/tables': [ tableKeys.list(ref, schema, { includeColumns: true }), diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx index 41a3a40d447..81f8edc91c2 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx @@ -9,7 +9,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ButtonTooltip } from '../ButtonTooltip' import { AIAssistantChatSelector } from './AIAssistantChatSelector' @@ -39,7 +39,7 @@ export const AIAssistantHeader = ({ const [isOptInModalOpen, setIsOptInModalOpen] = useState(false) return ( <div className="z-30 sticky top-0"> - <div className="border-b border-b-muted flex items-center bg gap-x-4 pl-4 pr-3 min-h-(--header-height)"> + <div className="border-b border-b-muted flex items-center bg-card gap-x-4 pl-4 pr-3 min-h-(--header-height)"> <div className="text-sm flex-1 flex items-center"> <AiIconAnimation size={20} allowHoverEffect={false} /> <span className="text-border-stronger dark:text-border-strong ml-3"> diff --git a/apps/studio/components/ui/AIAssistantPanel/hooks.ts b/apps/studio/components/ui/AIAssistantPanel/hooks.ts deleted file mode 100644 index 70a2f231bdc..00000000000 --- a/apps/studio/components/ui/AIAssistantPanel/hooks.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -interface UseAutoScrollProps { - enabled?: boolean -} - -export function useAutoScroll({ enabled = true }: UseAutoScrollProps = {}) { - const [container, setContainer] = useState<HTMLDivElement | null>(null) - const [isSticky, setIsSticky] = useState(true) - const isStickyRef = useRef(true) - const lastScrollHeightRef = useRef<number>(null) - - const ref = useCallback((element: HTMLDivElement | null) => { - if (element) { - setContainer(element) - } - }, []) - - const scrollToEnd = useCallback(() => { - if (container) { - isStickyRef.current = true - setIsSticky(true) - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }) - } - }, [container]) - - useEffect(() => { - if (!container || !enabled) return - - let timeoutId: NodeJS.Timeout - - const resizeObserver = new ResizeObserver(() => { - clearTimeout(timeoutId) - timeoutId = setTimeout(() => { - if ( - lastScrollHeightRef.current != null && - container.scrollHeight !== lastScrollHeightRef.current - ) { - lastScrollHeightRef.current = container.scrollHeight - if (isStickyRef.current) { - scrollToEnd() - } - } - }, 100) - }) - - const handleScroll = () => { - const isAtBottom = - Math.abs(container.scrollHeight - container.scrollTop - container.clientHeight) < 10 - - isStickyRef.current = isAtBottom - setIsSticky(isAtBottom) - } - - // Observe all children of the container - Array.from(container.children).forEach((child) => { - resizeObserver.observe(child) - }) - - container.addEventListener('scroll', handleScroll) - - return () => { - clearTimeout(timeoutId) - resizeObserver.disconnect() - container.removeEventListener('scroll', handleScroll) - } - }, [container, enabled, scrollToEnd]) - - return { ref, isSticky, scrollToEnd, setIsSticky } -} diff --git a/apps/studio/components/ui/AIEditor/index.tsx b/apps/studio/components/ui/AIEditor/index.tsx index 63076cff831..bf13b87850b 100644 --- a/apps/studio/components/ui/AIEditor/index.tsx +++ b/apps/studio/components/ui/AIEditor/index.tsx @@ -1,23 +1,25 @@ -import Editor, { Monaco, OnMount } from '@monaco-editor/react' +import { Monaco, OnMount } from '@monaco-editor/react' import { AnimatePresence, motion } from 'framer-motion' import type { editor as monacoEditor } from 'monaco-editor' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { KeyboardShortcut } from 'ui' -import { useSetCommandMenuOpen } from 'ui-patterns' +import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' +import { CodeEditor, type ValidLanguages } from '../CodeEditor/CodeEditor' import { DiffEditor } from '../DiffEditor' import ResizableAIWidget from './ResizableAIWidget' import { getEditorSelectionParts } from './utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { constructHeaders } from '@/data/fetchers' +import { useLatest } from '@/hooks/misc/useLatest' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' interface AIEditorProps { id?: string - language?: string + language: ValidLanguages value?: string defaultValue?: string aiEndpoint?: string @@ -71,14 +73,9 @@ export const AIEditor = ({ const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN) const setCommandMenuOpen = useSetCommandMenuOpen() - const executeQueryRef = useRef(executeQuery) - executeQueryRef.current = executeQuery - - const commandMenuHotkeyEnabledRef = useRef(isCommandMenuHotkeyEnabled) - commandMenuHotkeyEnabledRef.current = isCommandMenuHotkeyEnabled - - const setCommandMenuOpenRef = useRef(setCommandMenuOpen) - setCommandMenuOpenRef.current = setCommandMenuOpen + const executeQueryRef = useLatest(executeQuery) + const commandMenuHotkeyEnabledRef = useLatest(isCommandMenuHotkeyEnabled) + const setCommandMenuOpenRef = useLatest(setCommandMenuOpen) const [currentValue, setCurrentValue] = useState(value || defaultValue) const [isDiffMode, setIsDiffMode] = useState(false) @@ -218,15 +215,17 @@ export const AIEditor = ({ if (language === 'javascript' || language === 'typescript') { // The Deno libs are loaded as a raw text via raw-loader in next.config.ts. They're passed as raw text to the - // Monaco editor. - import('@/public/deno/edge-runtime.d.ts' as string) + // Monaco editor. The @vite-ignore stops Vite/Rolldown's dep scanner from following the dynamic import and + // trying to parse the .d.ts declarations as runtime modules — that crashes the scan in vite 8.0.13+ and + // cascades into SSR pre-bundling breakage. + import(/* @vite-ignore */ '@/public/deno/edge-runtime.d.ts' as string) .then((module) => { monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default) }) .catch((error) => { console.error('Failed to load Deno edge-runtime typings:', error) }) - import('@/public/deno/lib.deno.d.ts' as string) + import(/* @vite-ignore */ '@/public/deno/lib.deno.d.ts' as string) .then((module) => { monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default) }) @@ -235,17 +234,6 @@ export const AIEditor = ({ }) } - if (!!executeQueryRef.current) { - editor.addAction({ - id: 'run-query', - label: 'Run Query', - keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], - contextMenuGroupId: 'operation', - contextMenuOrder: 0, - run: () => executeQueryRef.current?.(), - }) - } - refreshCloseAction() // Add AI Assistant toggle keybinding (Cmd+I) @@ -279,11 +267,6 @@ export const AIEditor = ({ setCommandMenuOpenRef.current(true) } }) - - if (autoFocus) { - if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 }) - editor.focus() - } } const handlePrompt = async ( @@ -323,19 +306,6 @@ export const AIEditor = ({ } } - const defaultOptions: monacoEditor.IStandaloneEditorConstructionOptions = { - tabSize: 2, - fontSize: 13, - readOnly, - minimap: { enabled: false }, - wordWrap: 'on', - lineNumbers: 'on', - folding: false, - padding: { top: 4 }, - lineNumbersMinChars: 3, - ...options, - } - useEffect(() => { setCurrentValue(value || defaultValue) }, [value, defaultValue]) @@ -412,19 +382,20 @@ export const AIEditor = ({ </div> ) : ( <div className="w-full h-full relative"> - {/* [Joshen] Refactor: Use CodeEditor.tsx instead, reduce duplicate declaration of Editor */} - <Editor - theme="supabase" + <CodeEditor language={language} + autofocus={autoFocus} + className={className} + isReadOnly={readOnly} + options={options} value={currentValue} - options={defaultOptions} - onChange={(value: string | undefined) => { + onInputChange={(value) => { const newValue = value || '' setCurrentValue(newValue) onChange?.(newValue) }} onMount={handleEditorOnMount} - className={className} + actions={{ runQuery: { enabled: true, callback: () => executeQueryRef.current?.() } }} /> {promptState.isOpen && editorRef.current && ( <ResizableAIWidget diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx index b8f372f441b..dc231bd5080 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx @@ -1,6 +1,6 @@ import { AlertTriangle, ChevronRight, Inbox } from 'lucide-react' import { Badge, Button, cn } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import type { AdvisorItem } from './AdvisorPanel.types' import { diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx index 1b03c5a8b23..7af901f84e8 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx @@ -1,5 +1,6 @@ import { ChevronLeft, X } from 'lucide-react' import { Badge } from 'ui' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import type { AdvisorItem } from './AdvisorPanel.types' import { @@ -20,16 +21,9 @@ interface AdvisorPanelHeaderProps { export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => { const displayTitle = selectedItem ? getAdvisorPanelItemDisplayTitle(selectedItem) : undefined const secondaryText = selectedItem ? getAdvisorItemSecondaryText(selectedItem) : undefined - const metadataText = selectedItem - ? (secondaryText ?? - (selectedItem.createdAt ? formatItemDate(selectedItem.createdAt) : undefined)) - : undefined - // Only capitalize date strings (e.g. "a few seconds ago"); entity strings - // like "public.users" must not be case-altered. - const metadataCapitalize = - selectedItem !== undefined && - secondaryText === undefined && - selectedItem.createdAt !== undefined + const createdAt = selectedItem?.createdAt + + const metadataCapitalize = selectedItem !== undefined && secondaryText === undefined return ( <div className="border-b px-4 py-3 flex items-center gap-3"> @@ -43,13 +37,19 @@ export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPan <div className="flex items-center gap-2 overflow-hidden flex-1"> <div className="flex-1 flex flex-col"> <span className="heading-default">{displayTitle}</span> - {metadataText && ( + {selectedItem?.source !== 'notification' && secondaryText ? ( <span className={`text-xs text-foreground-light${metadataCapitalize ? ' capitalize-sentence' : ''}`} > - {metadataText} + {secondaryText} </span> - )} + ) : createdAt ? ( + <TimestampInfo + className="w-fit capitalize-sentence" + utcTimestamp={createdAt} + label={formatItemDate(createdAt)} + /> + ) : null} </div> {selectedItem && ( <Badge variant={severityBadgeVariants[selectedItem.severity]}> diff --git a/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx index d0ecf76f504..8952da6e26f 100644 --- a/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx +++ b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx @@ -40,13 +40,17 @@ export const NotificationDetail = ({ notification, onUpdateStatus }: Notificatio <Link title={organization.name} href={`/org/${organization.slug}/general`} - className="text-link" + className="text-sm text-link" > {organization.name} </Link> )} {project !== undefined && ( - <Link title={project.name} href={`/project/${project.ref}`} className="text-link"> + <Link + title={project.name} + href={`/project/${project.ref}`} + className="text-sm text-link" + > {project.name} </Link> )} diff --git a/apps/studio/components/ui/AlertError.tsx b/apps/studio/components/ui/AlertError.tsx index dc4b7805fe4..96746430a20 100644 --- a/apps/studio/components/ui/AlertError.tsx +++ b/apps/studio/components/ui/AlertError.tsx @@ -112,5 +112,3 @@ export const AlertError = ({ /> ) } - -export default AlertError diff --git a/apps/studio/components/ui/AlphaNotice.tsx b/apps/studio/components/ui/AlphaNotice.tsx index bcf8a80fe62..fe53c97d71d 100644 --- a/apps/studio/components/ui/AlphaNotice.tsx +++ b/apps/studio/components/ui/AlphaNotice.tsx @@ -2,7 +2,7 @@ import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { PropsWithChildren } from 'react' import { Badge, Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { BASE_PATH } from '@/lib/constants' diff --git a/apps/studio/components/ui/AutoEnableRLSNotice.tsx b/apps/studio/components/ui/AutoEnableRLSNotice.tsx index bb3b9e7e559..368678b418f 100644 --- a/apps/studio/components/ui/AutoEnableRLSNotice.tsx +++ b/apps/studio/components/ui/AutoEnableRLSNotice.tsx @@ -5,8 +5,6 @@ import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Button, - Card, - CardContent, Dialog, DialogContent, DialogDescription, @@ -17,6 +15,7 @@ import { DialogTitle, DialogTrigger, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } from '@/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants' @@ -60,32 +59,25 @@ export const AutoEnableRLSNotice = ({ iconOnly }: { iconOnly?: boolean }) => { } return ( - <Card> - <CardContent className="flex items-center justify-between"> - <div className="flex items-center gap-x-4"> - <div className="rounded-lg bg-surface-300 text-foreground-light w-10 h-10 flex items-center justify-center"> - <ShieldCheck size={18} /> - </div> - <div className="text-sm"> - <p>Auto-enable RLS for new tables</p> - <p className="text-foreground-lighter"> - Create an event trigger that enables Row Level Security on all new tables - </p> - </div> - </div> - - <div className="flex items-center gap-x-2"> + <Admonition + type="note" + layout="responsive" + title="Automatically enable Row Level Security (RLS) on new tables" + description="Protect future tables by automatically enabling RLS whenever a table is created." + actions={ + <> <CreateEnsureRLSTriggerDialog /> <ButtonTooltip icon={<X />} variant="text" - className="w-7" - tooltip={{ content: { side: 'bottom', text: 'Minimize' } }} + className="w-6" + tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }} + aria-label="Dismiss RLS notice" onClick={() => setIsMinimized(true)} /> - </div> - </CardContent> - </Card> + </> + } + /> ) } @@ -131,36 +123,41 @@ const CreateEnsureRLSTriggerDialog = ({ iconOnly }: { iconOnly?: boolean }) => { tooltip={{ content: { side: 'bottom', text: 'Auto-enable RLS for new tables' } }} /> ) : ( - <Button variant="primary">Learn more</Button> + <Button variant="default">Set up trigger</Button> )} </DialogTrigger> <DialogContent size="large"> <DialogHeader> - <DialogTitle>Automatically enable RLS for newly created tables</DialogTitle> - <DialogDescription>Secure your data using Postgres Row Level Security</DialogDescription> + <DialogTitle>Automatically enable RLS for new tables</DialogTitle> + <DialogDescription> + Protect future tables with a built-in database trigger. + </DialogDescription> </DialogHeader> <DialogSectionSeparator /> <DialogSection className="text-sm flex flex-col gap-y-2"> <p> - Tables in exposed schemas (default being the{' '} - <code className="text-code-inline">public</code> schema) are accessible to anyone. - Hence, we highly recommend enabling RLS on all such tables. + Tables in exposed schemas such as <code className="text-code-inline">public</code> are + reachable through your project’s API. Enable Row Level Security (RLS) on these tables so + access is governed by the policies you define, not just the project API key. </p> <p> - You can set up a database trigger to enable RLS automatically on all new tables with the - following SQL: + This trigger automatically enables RLS whenever a new table is created. Review the SQL + before creating it: </p> </DialogSection> - <CodeBlock language="sql" className="language-sql px-0 border-x-0 rounded-none h-64"> + <CodeBlock + language="sql" + className="language-sql px-0 border-x-0 border-b-0 rounded-none h-64" + > {AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL.trim()} </CodeBlock> <DialogFooter> <Button variant="default" disabled={isCreating} onClick={() => setOpen(false)}> - Close + Cancel </Button> <ButtonTooltip disabled={!canCreateTriggers} @@ -175,7 +172,7 @@ const CreateEnsureRLSTriggerDialog = ({ iconOnly }: { iconOnly?: boolean }) => { }, }} > - Create ensure_rls trigger + Create trigger </ButtonTooltip> </DialogFooter> </DialogContent> diff --git a/apps/studio/components/ui/Charts/ChartHandler.tsx b/apps/studio/components/ui/Charts/ChartHandler.tsx deleted file mode 100644 index 52b5921ab47..00000000000 --- a/apps/studio/components/ui/Charts/ChartHandler.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import dayjs from 'dayjs' -import { Activity, BarChartIcon, Loader2 } from 'lucide-react' -import { useRouter } from 'next/router' -import { PropsWithChildren, useMemo, useState } from 'react' -import { Button, Tooltip, TooltipContent, TooltipTrigger, WarningIcon } from 'ui' - -import type { ChartData } from './Charts.types' -import AreaChart from '@/components/ui/Charts/AreaChart' -import BarChart from '@/components/ui/Charts/BarChart' -import { AnalyticsInterval } from '@/data/analytics/constants' -import { mapMultiResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries' -import { - InfraMonitoringAttribute, - useInfraMonitoringAttributesQuery, -} from '@/data/analytics/infra-monitoring-query' -import { - ProjectDailyStatsAttribute, - useProjectDailyStatsQuery, -} from '@/data/analytics/project-daily-stats-query' -import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' - -interface ChartHandlerProps { - id?: string - label: string - attribute: string - provider: 'infra-monitoring' | 'daily-stats' - startDate: string - endDate: string - interval: string - customDateFormat?: string - defaultChartStyle?: 'bar' | 'line' - hideChartType?: boolean - data?: ChartData - isLoading?: boolean - format?: string - highlightedValue?: string | number - syncId?: string -} - -/** - * Controls chart display state. Optionally fetches static chart data if data is not provided. - * - * If the `data` prop is provided, it will disable automatic chart data fetching and pass the data directly to the chart render. - * - loading state can also be provided through the `isLoading` prop, to display loading placeholders. Ignored if `data` key not provided. - * - if `isLoading=true` and `data` is `undefined`, loading error message will be shown. - * - * Provided data must be in the expected chart format. - */ -const ChartHandler = ({ - label, - attribute, - provider, - startDate, - endDate, - interval, - customDateFormat, - children = null, - defaultChartStyle = 'bar', - hideChartType = false, - data, - isLoading, - format, - highlightedValue, - syncId, - ...otherProps -}: PropsWithChildren<ChartHandlerProps>) => { - const router = useRouter() - const { ref } = router.query - - const state = useDatabaseSelectorStateSnapshot() - const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle) - - const databaseIdentifier = state.selectedDatabaseId - - const { data: dailyStatsData, isPending: isFetchingDailyStats } = useProjectDailyStatsQuery( - { - projectRef: ref as string, - attribute: attribute as ProjectDailyStatsAttribute, - startDate: dayjs(startDate).format('YYYY-MM-DD'), - endDate: dayjs(endDate).format('YYYY-MM-DD'), - }, - { enabled: provider === 'daily-stats' && data === undefined } - ) - - const { data: infraMonitoringData, isPending: isFetchingInfraMonitoring } = - useInfraMonitoringAttributesQuery( - { - projectRef: ref as string, - attributes: [attribute as InfraMonitoringAttribute], - startDate, - endDate, - interval: interval as AnalyticsInterval, - databaseIdentifier, - }, - { enabled: provider === 'infra-monitoring' && data === undefined } - ) - - const transformedInfraData = useMemo(() => { - if (!infraMonitoringData) return undefined - const mapped = mapMultiResponseToAnalyticsData(infraMonitoringData, [ - attribute as InfraMonitoringAttribute, - ]) - return mapped[attribute] - }, [infraMonitoringData, attribute]) - - const chartData = - data || - (provider === 'infra-monitoring' - ? transformedInfraData - : provider === 'daily-stats' - ? dailyStatsData - : undefined) - - const loading = - isLoading || - (provider === 'infra-monitoring' - ? isFetchingInfraMonitoring - : provider === 'daily-stats' - ? isFetchingDailyStats - : isLoading) - - const shouldHighlightMaxValue = - provider === 'daily-stats' && - !attribute.includes('ingress') && - !attribute.includes('egress') && - chartData !== undefined && - 'maximum' in chartData - const shouldHighlightTotalGroupedValue = chartData !== undefined && 'totalGrouped' in chartData - - const _highlightedValue = - highlightedValue !== undefined - ? highlightedValue - : shouldHighlightMaxValue - ? chartData?.maximum - : provider === 'daily-stats' - ? chartData?.total - : shouldHighlightTotalGroupedValue - ? chartData?.totalGrouped?.[attribute] - : (chartData?.data[chartData?.data.length - 1] as any)?.[attribute as any] - - if (loading) { - return ( - <div className="flex h-52 w-full flex-col items-center justify-center gap-y-2"> - <Loader2 size={18} className="animate-spin text-border-strong" /> - <p className="text-xs text-foreground-lighter">Loading data for {label}</p> - </div> - ) - } - - if (chartData === undefined) { - return ( - <div className="flex h-64 w-full flex-col items-center justify-center gap-y-2 border border-dashed rounded-md"> - <WarningIcon /> - <p className="text-xs text-foreground-lighter">Unable to load data for {label}</p> - </div> - ) - } - - return ( - <div className="h-full w-full"> - <div className="absolute right-6 z-10 flex justify-between"> - {!hideChartType && ( - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="default" - className="px-1.5" - icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />} - onClick={() => setChartStyle(chartStyle === 'bar' ? 'line' : 'bar')} - /> - </TooltipTrigger> - <TooltipContent side="left" align="center"> - View as {chartStyle === 'bar' ? 'line chart' : 'bar chart'} - </TooltipContent> - </Tooltip> - )} - {children} - </div> - {chartStyle === 'bar' ? ( - <BarChart - data={(chartData?.data ?? []) as any} - format={format || chartData?.format} - xAxisKey={'period_start'} - yAxisKey={attribute} - highlightedValue={_highlightedValue} - title={label} - customDateFormat={customDateFormat} - syncId={syncId} - {...otherProps} - /> - ) : ( - <AreaChart - data={(chartData?.data ?? []) as any} - format={format || chartData?.format} - xAxisKey="period_start" - yAxisKey={attribute} - highlightedValue={_highlightedValue} - title={label} - customDateFormat={customDateFormat} - syncId={syncId} - {...otherProps} - /> - )} - </div> - ) -} - -export default ChartHandler diff --git a/apps/studio/components/ui/Charts/Charts.constants.ts b/apps/studio/components/ui/Charts/Charts.constants.ts index 4ec14cdd103..53441ca068c 100644 --- a/apps/studio/components/ui/Charts/Charts.constants.ts +++ b/apps/studio/components/ui/Charts/Charts.constants.ts @@ -3,14 +3,14 @@ // For ChartHandler export const CHART_COLORS = { - TICK: 'hsl(var(--background-overlay-hover))', - AXIS: 'hsl(var(--background-overlay-hover))', - GREEN_1: 'hsl(var(--brand-default))', // #3ECF8E + TICK: 'var(--background-overlay-hover)', + AXIS: 'var(--background-overlay-hover)', + GREEN_1: 'hsl(var(--brand-default))', GREEN_2: 'hsl(var(--brand-500))', RED_1: 'hsl(var(--destructive-default))', RED_2: 'hsl(var(--destructive-500))', - REFERENCE_LINE: 'hsl(var(--foreground-muted))', - REFERENCE_LINE_TEXT: 'hsl(var(--foreground-muted))', + REFERENCE_LINE: 'var(--foreground-muted)', + REFERENCE_LINE_TEXT: 'var(--foreground-muted)', } const LIGHT_STACKED_CHART_COLORS = [ diff --git a/apps/studio/components/ui/Charts/ReportSettings.tsx b/apps/studio/components/ui/Charts/ReportSettings.tsx index 2b1299c1b92..17248926ddf 100644 --- a/apps/studio/components/ui/Charts/ReportSettings.tsx +++ b/apps/studio/components/ui/Charts/ReportSettings.tsx @@ -1,6 +1,6 @@ import { Settings } from 'lucide-react' import { useState } from 'react' -import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, Label, Switch } from 'ui' +import { Label, Popover, PopoverContent, PopoverTrigger, Switch } from 'ui' import { useChartHoverState } from './useChartHoverState' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -14,46 +14,43 @@ export const ReportSettings = ({ chartId }: ReportSettingsProps) => { const { syncHover, syncTooltip, setSyncHover, setSyncTooltip } = useChartHoverState(chartId) return ( - <DropdownMenu open={isOpen} onOpenChange={setIsOpen}> - <DropdownMenuTrigger asChild> + <Popover open={isOpen} onOpenChange={setIsOpen}> + <PopoverTrigger asChild> <ButtonTooltip variant="default" icon={<Settings />} className="w-7" tooltip={{ content: { side: 'bottom', text: 'Report settings' } }} /> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" side="bottom" className="w-64 p-3"> - <div className="space-y-4"> - <Label htmlFor="sync-hover" className="text-sm font-normal"> - <div className="flex items-center justify-between space-x-2"> - Sync chart headers - <Switch id="sync-hover" checked={syncHover} onCheckedChange={setSyncHover} /> - </div> - <p className="text-xs text-foreground-light mt-1"> - When enabled, hovering over any chart will update headers across all charts - </p> - </Label> - - <Label htmlFor="sync-tooltips" className="text-sm font-normal flex flex-col"> - <div className="flex items-center justify-between space-x-2"> - Sync tooltips - <Switch - id="sync-tooltips" - checked={syncTooltip} - disabled={!syncHover} - onCheckedChange={setSyncTooltip} - /> - </div> - <p className="text-xs text-foreground-light mt-1"> - When enabled, also shows tooltips on all charts.{' '} - <span className={cn(syncHover ? 'text-foreground-light' : 'text-foreground')}> - Requires header sync. - </span> + </PopoverTrigger> + <PopoverContent align="center" side="bottom" className="w-64 p-3 flex flex-col gap-y-4"> + <div className="flex items-start justify-between space-x-2"> + <Label htmlFor="sync-hover" className="text-xs"> + <p>Sync chart headers</p> + <p className="text-xs text-foreground-light mt-1 text-balance"> + Hovering over any chart will update headers across all charts </p> </Label> + <Switch id="sync-hover" checked={syncHover} onCheckedChange={setSyncHover} /> </div> - </DropdownMenuContent> - </DropdownMenu> + + {syncHover && ( + <div className="flex items-start justify-between space-x-2"> + <Label htmlFor="sync-tooltips" className="text-xs"> + <p>Sync tooltips</p> + <p className="text-xs text-foreground-light mt-1 text-balance"> + Shows tooltips on all charts + </p> + </Label> + <Switch + id="sync-tooltips" + checked={syncTooltip} + disabled={!syncHover} + onCheckedChange={setSyncTooltip} + /> + </div> + )} + </PopoverContent> + </Popover> ) } diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx new file mode 100644 index 00000000000..d4f6d10fea7 --- /dev/null +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { CodeEditor } from './CodeEditor' +import { render } from '@/tests/helpers' + +/** + * CodeEditor applies a default `h-full` so editors with no explicit height fill their container + * (GraphiQL, etc.). Callers that DO set a height (e.g. the email template editor's `h-96`) must + * win — otherwise the editor collapses to a single line. + * + * This regressed once already: #47339 appended the default as `cn(className, 'monaco-editor', + * 'h-full')`, and tailwind-merge keeps the *last* conflicting height utility, so the trailing + * `h-full` clobbered caller heights. #47350 fixed it by passing `className` last. These tests + * lock that ordering in. + * + * The `<div className={...}>` that receives the class is rendered by @monaco-editor/react before + * Monaco loads, so it's present in jsdom without a working editor. + */ +describe('CodeEditor height class precedence', () => { + const getEditorEl = (container: HTMLElement) => container.querySelector('.monaco-editor') + + it('lets a caller-supplied height win over the default h-full (regression #47350)', () => { + const { container } = render(<CodeEditor language="pgsql" className="h-96" />) + + const editor = getEditorEl(container) + expect(editor, 'editor wrapper should render').toBeTruthy() + expect(editor).toHaveClass('h-96') + expect(editor, 'default h-full must not override the caller height').not.toHaveClass('h-full') + }) + + it('falls back to the default h-full when the caller sets no height', () => { + const { container } = render(<CodeEditor language="pgsql" />) + + const editor = getEditorEl(container) + expect(editor, 'editor wrapper should render').toBeTruthy() + expect(editor).toHaveClass('h-full') + }) +}) diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index c9d5f4d2f9f..f912bdf6e49 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -1,11 +1,12 @@ import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react' import { merge, noop } from 'lodash' +import { Loader2 } from 'lucide-react' import type { editor } from 'monaco-editor' import { RefObject, useEffect, useRef, useState } from 'react' -import { cn, LogoLoader } from 'ui' +import { cn } from 'ui' import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' -import { alignEditor } from './CodeEditor.utils' +import { alignEditor, BASE_MONACO_EDITOR_OPTIONS } from './CodeEditor.utils' import { Markdown } from '@/components/interfaces/Markdown' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -21,14 +22,26 @@ const DEFAULT_ACTIONS = { placeholderFill: { enabled: true }, } +export type ValidLanguages = + | 'pgsql' + | 'json' + | 'html' + | 'typescript' + | 'javascript' + | 'css' + | 'csv' + | 'plaintext' + | 'markdown' + interface CodeEditorProps { id?: string - language: 'pgsql' | 'json' | 'html' | 'typescript' | 'plaintext' | undefined + language: ValidLanguages autofocus?: boolean defaultValue?: string isReadOnly?: boolean hideLineNumbers?: boolean className?: string + wrapperClassName?: string loading?: boolean options?: EditorProps['options'] value?: string @@ -57,6 +70,7 @@ export const CodeEditor = ({ isReadOnly = false, hideLineNumbers = false, className, + wrapperClassName, loading, options, value, @@ -95,18 +109,13 @@ export const CodeEditor = ({ const optionsMerged = merge( { - tabSize: 2, - fontSize: 13, + ...BASE_MONACO_EDITOR_OPTIONS, + domReadOnly: isReadOnly, readOnly: isReadOnly, - minimap: { enabled: false }, - wordWrap: 'on', - fixedOverflowWidgets: true, - contextmenu: true, lineNumbers: hideLineNumbers ? 'off' : undefined, glyphMargin: hideLineNumbers ? false : undefined, lineNumbersMinChars: hideLineNumbers ? 0 : 4, folding: hideLineNumbers ? false : undefined, - scrollBeyondLastLine: false, }, options ) @@ -245,11 +254,18 @@ export const CodeEditor = ({ <Editor path={id} theme="supabase" - className={cn(className, 'monaco-editor')} + // `h-full` keeps this wrapper filling its container even if a global `.monaco-editor` + // rule flips it to `position: absolute` (which happens after visiting GraphiQL, since it + // injects a second copy of Monaco's CSS onto the shared instance). Without an explicit + // height, an absolutely-positioned wrapper collapses to 0 and Monaco lays out at ~5px. + // Order matters: `h-full` is a default, so a caller-supplied height in `className` + // (e.g. `h-96`) wins via tailwind-merge instead of being clobbered. + className={cn('monaco-editor', 'h-full', className)} + wrapperProps={{ className: wrapperClassName }} value={value ?? undefined} language={language} defaultValue={defaultValue ?? undefined} - loading={loading || <LogoLoader />} + loading={loading || <Loader2 className="animate-spin" strokeWidth={2} size={20} />} options={optionsMerged} onMount={handleMount} onChange={onChangeContent} diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts b/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts index 5540d9d1456..a33b18bf56e 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts @@ -1,6 +1,26 @@ -export const alignEditor = (editor: any) => { +import type { editor } from 'monaco-editor' + +/** + * Base Monaco editor options shared across Studio's editors so font size, indentation and + * chrome stay consistent everywhere a Monaco editor is rendered. CodeEditor layers per-instance + * options (read-only, line numbers) on top; GraphiQL (which runs its own Monaco instance) layers + * its own padding/glyphMargin on top via `editor.updateOptions`. + * + * Keep this as the single source of truth — don't redeclare these values at call sites. + */ +export const BASE_MONACO_EDITOR_OPTIONS: editor.IStandaloneEditorConstructionOptions = { + tabSize: 2, + fontSize: 13, + minimap: { enabled: false }, + wordWrap: 'on', + fixedOverflowWidgets: true, + contextmenu: true, + scrollBeyondLastLine: false, +} + +export const alignEditor = (editor: editor.IStandaloneCodeEditor) => { // Add margin above first line - editor.changeViewZones((accessor: any) => { + editor.changeViewZones((accessor) => { accessor.addZone({ afterLineNumber: 0, heightInPx: 4, diff --git a/apps/studio/components/ui/CountdownTimer/CountdownTimerRadial.tsx b/apps/studio/components/ui/CountdownTimer/CountdownTimerRadial.tsx index 487d36c618e..dc91a0ebc9a 100644 --- a/apps/studio/components/ui/CountdownTimer/CountdownTimerRadial.tsx +++ b/apps/studio/components/ui/CountdownTimer/CountdownTimerRadial.tsx @@ -35,7 +35,7 @@ const CountdownTimerRadial = ({ progress }: CountdownTimerRadialProps) => { className="first:fill-foreground-muted/50 last:fill-background-200" polarRadius={[16, 11]} /> - <RadialBar dataKey="timeRemaining" cornerRadius={2} /> + <RadialBar dataKey="timeRemaining" cornerRadius={2} isAnimationActive={false} /> </RadialBarChart> </ChartContainer> @@ -48,7 +48,7 @@ const CountdownTimerRadial = ({ progress }: CountdownTimerRadialProps) => { innerRadius={14} outerRadius={5} > - <RadialBar dataKey="hand" cornerRadius={2} isAnimationActive={true} /> + <RadialBar dataKey="hand" cornerRadius={2} isAnimationActive={false} /> </RadialBarChart> </ChartContainer> </div> diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts index cb500d6aedb..ac3fd3937cb 100644 --- a/apps/studio/components/ui/DataTable/DataTable.types.ts +++ b/apps/studio/components/ui/DataTable/DataTable.types.ts @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' export type Option = { label: string value: string + options?: Option[] } export type DatePreset = { diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx deleted file mode 100644 index ee6f6127aec..00000000000 --- a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { type Column } from '@tanstack/react-table' -import { TanStackTableHeadSort } from 'ui-patterns/Table' - -interface DataTableColumnHeaderProps<TData, TValue> { - column: Column<TData, TValue> - title: string - className?: string -} - -/** - * @deprecated Use `TanStackTableHeadSort` from `ui-patterns/Table` instead. - */ -export const DataTableColumnHeader = <TData, TValue>({ - column, - title, - className, -}: DataTableColumnHeaderProps<TData, TValue>) => { - return ( - <TanStackTableHeadSort column={column} className={className}> - {title} - </TanStackTableHeadSort> - ) -} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx index 4905fae0397..69bf1912450 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx @@ -1,4 +1,5 @@ -import { Search } from 'lucide-react' +import { Minus, Plus, Search } from 'lucide-react' +import { useQueryStates } from 'nuqs' import { useState } from 'react' import { Checkbox, cn, Label, Skeleton } from 'ui' @@ -7,6 +8,7 @@ import { formatCompactNumber } from '../DataTable.utils' import { InputWithAddons } from '../primitives/InputWithAddons' import { useDataTable } from '../providers/DataTableProvider' import { DataTableFilterCheckboxLoader } from './DataTableFilterCheckboxLoader' +import { SEARCH_PARAMS_PARSER } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.constants' export function DataTableFilterCheckbox<TData>({ value: _value, @@ -15,6 +17,22 @@ export function DataTableFilterCheckbox<TData>({ }: DataTableCheckboxFilterField<TData>) { const value = _value as string const [inputValue, setInputValue] = useState('') + const [expandedOptions, setExpandedOptions] = useState<Set<string>>(new Set()) + const [searchParams, setSearchParams] = useQueryStates(SEARCH_PARAMS_PARSER) + + // Nested options map 1:1 to a boolean search param via their `value` + // (e.g. `show_connection_logs`), so they can be read/written generically. + const getBooleanParam = (key: string) => Boolean(searchParams[key as keyof typeof searchParams]) + const setBooleanParam = (key: string, val: boolean) => + setSearchParams({ [key]: val } as Partial<typeof searchParams>) + + const toggleExpanded = (key: string) => + setExpandedOptions((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) const { table, columnFilters, isLoading, isLoadingCounts, getFacetedUniqueValues } = useDataTable() @@ -56,8 +74,7 @@ export function DataTableFilterCheckbox<TData>({ onChange={(e) => setInputValue(e.target.value)} /> ) : null} - {/* FIXME: due to the added max-h and overflow-y-auto, the hover state and border is laying on top of the scroll bar */} - <div className="max-h-[200px] overflow-y-auto rounded-sm border border-border empty:border-none"> + <div className="max-h-[215px] overflow-y-auto rounded-sm border border-border empty:border-none"> {filterOptions.length === 0 && inputValue !== '' ? ( <div className="flex items-center justify-center px-2 py-4 text-center"> <div className="space-y-0.5"> @@ -66,18 +83,20 @@ export function DataTableFilterCheckbox<TData>({ </div> </div> ) : ( - filterOptions - // TODO: we shoudn't sort the options here, instead filterOptions should be sorted by default - // .sort((a, b) => a.label.localeCompare(b.label)) - .map((option, index) => { - const checked = filters.includes(option.value) + filterOptions.map((option, index) => { + const checked = filters.includes(option.value) + const optionKey = String(option.value) + const hasNested = (option.options ?? []).length > 0 + const isExpanded = expandedOptions.has(optionKey) - return ( + return ( + <div + key={String(option.value)} + className={cn('py-2', index !== filterOptions.length - 1 ? 'border-b' : undefined)} + > <div - key={String(option.value)} className={cn( - 'group relative flex items-center space-x-2 px-2 py-2 hover:bg-accent/50', - index !== filterOptions.length - 1 ? 'border-b' : undefined + 'group relative flex items-center space-x-2 px-2 hover:bg-accent/50' )} > <Checkbox @@ -92,7 +111,7 @@ export function DataTableFilterCheckbox<TData>({ /> <Label htmlFor={`${value}-${option.value}`} - className="flex w-full items-center justify-between gap-2 text-foreground/70 group-hover:text-accent-foreground text-[0.8rem] min-w-0" + className="relative flex w-full items-center justify-between gap-2 text-foreground/70 group-hover:text-accent-foreground text-[0.8rem] min-w-0" > <div className="flex-1 min-w-0 overflow-hidden"> {Component ? ( @@ -101,6 +120,21 @@ export function DataTableFilterCheckbox<TData>({ <span className="truncate font-normal block">{option.label}</span> )} </div> + {hasNested && ( + <button + type="button" + aria-label={isExpanded ? 'Collapse' : 'Expand'} + aria-expanded={isExpanded} + onClick={() => toggleExpanded(optionKey)} + className={cn( + 'flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-foreground-lighter', + 'hover:bg-selection hover:text-foreground', + 'absolute top-0 right-10' + )} + > + {isExpanded ? <Minus size={12} /> : <Plus size={12} />} + </button> + )} <span className="shrink-0 flex items-center justify-center font-mono text-xs"> {isLoadingCounts ? ( <Skeleton className="h-4 w-4" /> @@ -114,16 +148,65 @@ export function DataTableFilterCheckbox<TData>({ type="button" onClick={() => column?.setFilterValue([option.value])} className={cn( - 'absolute inset-y-0 right-0 hidden font-normal text-muted-foreground backdrop-blur-xs hover:text-foreground group-hover:block', + 'absolute inset-y-0 right-0 -top-0.5 hidden font-normal text-muted-foreground backdrop-blur-xs hover:text-foreground group-hover:block', 'rounded-md ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' )} > - <span className="px-2">only</span> + <span className="pr-2">only</span> </button> </Label> </div> - ) - }) + + {hasNested && + isExpanded && + option.options?.map((optionNested, nestedIndex) => { + const nestedChecked = getBooleanParam(optionNested.value) + const isLastNested = nestedIndex === (option.options?.length ?? 0) - 1 + + return ( + <div + key={optionNested.value} + className={cn( + 'group/nested relative flex items-stretch', + nestedIndex === 0 && '[&>div:last-child]:pt-2' + )} + > + <div aria-hidden className="relative w-7 shrink-0"> + {isLastNested ? ( + <span + className={cn( + 'absolute left-4 top-0 w-3 rounded-bl-sm border-b border-l border-border', + option.options?.length === 1 ? 'h-[60%]' : 'h-1/2' + )} + /> + ) : ( + <> + <span className="absolute left-4 top-0 h-full w-px bg-border" /> + <span className="absolute left-4 top-[55%] h-px w-3 bg-border" /> + </> + )} + </div> + <div className="flex flex-1 items-center gap-x-2 rounded-sm py-1 pr-2 hover:bg-accent/50 min-w-0"> + <Checkbox + id={`${value}-${optionNested.value}`} + checked={nestedChecked} + onCheckedChange={(isChecked) => + setBooleanParam(optionNested.value, Boolean(isChecked)) + } + /> + <Label + htmlFor={`${value}-${optionNested.value}`} + className="flex w-full cursor-pointer items-center text-[0.8rem] font-normal text-foreground/70 group-hover/nested:text-accent-foreground min-w-0" + > + <span className="truncate text-xs">{optionNested.label}</span> + </Label> + </div> + </div> + ) + })} + </div> + ) + }) )} </div> </div> diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx index 676abf9b672..57b1f166306 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx @@ -66,7 +66,7 @@ export function DataTableFilterCheckboxAsync<TData>({ onChange={(e) => setInputValue(e.target.value)} /> - <div className="max-h-[200px] overflow-y-auto rounded-sm border border-border empty:border-none"> + <div className="max-h-[215px] overflow-y-auto rounded-sm border border-border empty:border-none"> {filterOptions.length === 0 ? ( <div className="flex items-center justify-center px-2 py-3 text-center"> <div className="space-y-0.5"> diff --git a/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx deleted file mode 100644 index 3a7f19778b9..00000000000 --- a/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { forwardRef, PropsWithChildren, useEffect, useRef } from 'react' -import { cn } from 'ui' - -export const DataTableHeaderLayout = forwardRef< - HTMLDivElement, - PropsWithChildren<{ - setTopBarHeight: (height: number) => void - }> ->(({ setTopBarHeight, ...props }, _ref) => { - const topBarRef = useRef<HTMLDivElement>(null) - - useEffect(() => { - const observer = new ResizeObserver(() => { - const rect = topBarRef.current?.getBoundingClientRect() - if (rect) { - setTopBarHeight(rect.height) - } - }) - - const topBar = topBarRef.current - if (!topBar) return - - observer.observe(topBar) - return () => observer.unobserve(topBar) - }, [topBarRef]) - - return ( - <div - ref={topBarRef} - className={cn('flex flex-col gap-4 bg-background p-2', 'top-0 z-10 pb-4')} - {...props} - /> - ) -}) -DataTableHeaderLayout.displayName = 'DataTableHeaderLayout' diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx index 8ca89276707..a29ac3b1c6c 100644 --- a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx +++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx @@ -5,7 +5,7 @@ import { LoaderCircle } from 'lucide-react' import { useQueryState } from 'nuqs' import { Fragment, UIEvent, useCallback, useRef } from 'react' import { Button, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { AlertError } from '../AlertError' import { formatCompactNumber } from './DataTable.utils' diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx deleted file mode 100644 index cee10d24cd9..00000000000 --- a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { ChevronDown, ChevronUp, X } from 'lucide-react' -import { ReactNode, useCallback, useEffect, useMemo } from 'react' -import { Button, cn, Separator, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui' - -import { Kbd } from './primitives/Kbd' -import { useDataTable } from './providers/DataTableProvider' - -export interface DataTableSheetDetailsProps { - title?: ReactNode - titleClassName?: string - children?: ReactNode -} - -export function DataTableSheetDetails({ - title, - titleClassName, - children, -}: DataTableSheetDetailsProps) { - const { table, rowSelection, isLoading } = useDataTable() - - const selectedRowKey = Object.keys(rowSelection)?.[0] - - const selectedRow = useMemo(() => { - if (isLoading && !selectedRowKey) return - return table.getRowModel().rows.find((row) => row.id === selectedRowKey) - }, [selectedRowKey, isLoading, table]) - - const index = table.getRowModel().rows.findIndex((row) => row.id === selectedRow?.id) - - const nextId = useMemo(() => table.getRowModel().rows[index + 1]?.id, [index, table]) - - const prevId = useMemo(() => table.getRowModel().rows[index - 1]?.id, [index, table]) - - const onPrev = useCallback(() => { - if (prevId) table.setRowSelection({ [prevId]: true }) - }, [prevId, table]) - - const onNext = useCallback(() => { - if (nextId) table.setRowSelection({ [nextId]: true }) - }, [nextId, table]) - - useEffect(() => { - const down = (e: KeyboardEvent) => { - if (!selectedRowKey) return - - // REMINDER: prevent dropdown navigation inside of sheet to change row selection - const activeElement = document.activeElement - const isMenuActive = activeElement?.closest('[role="menu"]') - - if (isMenuActive) return - - if (e.key === 'ArrowUp') { - e.preventDefault() - onPrev() - } - if (e.key === 'ArrowDown') { - e.preventDefault() - onNext() - } - } - - document.addEventListener('keydown', down) - return () => document.removeEventListener('keydown', down) - }, [selectedRowKey, onNext, onPrev]) - - return ( - <div className="relative bg-sidebar"> - <div className="flex items-center justify-between gap-2 pl-5 pr-2 py-1"> - <h5 className={cn(titleClassName, 'truncate text-left')}> - {isLoading && !selectedRowKey ? <Skeleton className="h-7 w-36" /> : title} - </h5> - <div className="flex h-7 items-center gap-1"> - <Tooltip> - <TooltipTrigger asChild> - <Button - size="tiny" - variant="text" - disabled={!prevId} - onClick={onPrev} - className="px-1" - icon={<ChevronUp />} - /> - </TooltipTrigger> - <TooltipContent> - <p> - Navigate <Kbd>↑</Kbd> - </p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Button - size="tiny" - variant="text" - disabled={!nextId} - onClick={onNext} - className="px-1" - icon={<ChevronDown />} - /> - </TooltipTrigger> - <TooltipContent> - <p> - Navigate <Kbd>↓</Kbd> - </p> - </TooltipContent> - </Tooltip> - <Separator orientation="vertical" className="mx-1" /> - - <Button - size="tiny" - variant="text" - onClick={() => table.resetRowSelection()} - className="px-1" - icon={<X />} - /> - </div> - </div> - <Separator /> - {children} - </div> - ) -} diff --git a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx deleted file mode 100644 index 7deab48fb3b..00000000000 --- a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' -import { ReactNode, useMemo } from 'react' -import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' - -import { formatCompactNumber } from './DataTable.utils' -import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer' -import { DataTableResetButton } from './DataTableResetButton' -import { DataTableViewOptions } from './DataTableViewOptions' -import { Kbd } from './primitives/Kbd' -import { useDataTable } from './providers/DataTableProvider' -import { SHORTCUT_IDS } from '@/state/shortcuts/registry' -import { useShortcut } from '@/state/shortcuts/useShortcut' - -interface DataTableToolbarProps { - renderActions?: () => ReactNode - isFilterBarOpen: boolean - setIsFilterBarOpen: React.Dispatch<React.SetStateAction<boolean>> -} - -export function DataTableToolbar({ - renderActions, - isFilterBarOpen, - setIsFilterBarOpen, -}: DataTableToolbarProps) { - const { table, isLoading, columnFilters } = useDataTable() - const filters = table.getState().columnFilters - - useShortcut(SHORTCUT_IDS.DATA_TABLE_TOGGLE_FILTERS, () => setIsFilterBarOpen((prev) => !prev)) - - const rows = useMemo( - () => ({ - total: table.getCoreRowModel().rows.length, - filtered: table.getFilteredRowModel().rows.length, - }), - [isLoading, columnFilters] - ) - - return ( - <div className="flex flex-wrap items-center justify-between gap-4"> - <div className="flex flex-wrap items-center gap-2"> - <Tooltip> - <TooltipTrigger asChild> - <Button - size="tiny" - variant="default" - icon={isFilterBarOpen ? <PanelLeftClose /> : <PanelLeftOpen />} - onClick={() => setIsFilterBarOpen((prev) => !prev)} - className="hidden sm:flex" - > - <span className="hidden md:block">{isFilterBarOpen ? 'Hide' : 'Show'} Controls</span> - </Button> - </TooltipTrigger> - <TooltipContent side="right"> - <p> - Toggle controls with{' '} - <Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground"> - <span className="mr-1">⌘</span> - <span>B</span> - </Kbd> - </p> - </TooltipContent> - </Tooltip> - <div className="block sm:hidden"> - <DataTableFilterControlsDrawer /> - </div> - <div> - <p className="hidden text-sm text-muted-foreground sm:block"> - <span className="font-mono font-medium">{formatCompactNumber(rows.filtered)}</span> of{' '} - <span className="font-mono font-medium">{formatCompactNumber(rows.total)}</span> row(s){' '} - <span className="sr-only sm:not-sr-only">filtered</span> - </p> - <p className="block text-sm text-muted-foreground sm:hidden"> - <span className="font-mono font-medium">{formatCompactNumber(rows.filtered)}</span>{' '} - row(s) - </p> - </div> - </div> - <div className="ml-auto flex items-center gap-2"> - {filters.length ? <DataTableResetButton /> : null} - {renderActions?.()} - <DataTableViewOptions /> - </div> - </div> - ) -} diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx index afeb19bcd56..71fb110655c 100644 --- a/apps/studio/components/ui/DataTable/FilterSideBar.tsx +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -1,7 +1,6 @@ import { useParams } from 'common' import Link from 'next/link' -import { useRouter } from 'next/router' -import React, { useEffect, type ReactNode } from 'react' +import { cloneElement, Dispatch, SetStateAction, useEffect } from 'react' import { Badge, Button, cn, ResizablePanel, usePanelRef } from 'ui' import { FeaturePreviewSidebarPanel } from '../FeaturePreviewSidebarPanel' @@ -9,34 +8,23 @@ import { DateRangeDisabled } from './DataTable.types' import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls' import { DataTableResetButton } from './DataTableResetButton' import { useDataTable } from './providers/DataTableProvider' -import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { LOG_DRAIN_TYPES } from '@/components/interfaces/LogDrains/LogDrains.constants' import { UnifiedLogsBanner } from '@/components/interfaces/UnifiedLogs/UnifiedLogsBanner' interface FilterSideBarProps { isFilterBarOpen: boolean - setIsFilterBarOpen: React.Dispatch<React.SetStateAction<boolean>> + setIsFilterBarOpen: Dispatch<SetStateAction<boolean>> dateRangeDisabled?: DateRangeDisabled - afterFilters?: ReactNode } export function FilterSideBar({ isFilterBarOpen, setIsFilterBarOpen, dateRangeDisabled, - afterFilters, }: FilterSideBarProps) { - const router = useRouter() const { ref } = useParams() const { table } = useDataTable() - const { disable: disableUnifiedLogs, isEligible: isUnifiedLogsEligible } = useUnifiedLogsPreview() - - const handleGoBackToOldLogs = () => { - disableUnifiedLogs() - router.push(`/project/${ref}/logs/explorer`) - } - const panelRef = usePanelRef() useEffect(() => { @@ -68,23 +56,16 @@ export function FilterSideBar({ <div className="flex h-[48px] items-center justify-between gap-3"> <div className="flex items-center gap-2"> <p className="text-foreground text-lg">Logs</p> - {isUnifiedLogsEligible && <Badge variant="default">Beta</Badge>} + <Badge variant="default">Beta</Badge> </div> {table.getState().columnFilters.length ? <DataTableResetButton /> : null} </div> </div> - {isUnifiedLogsEligible && ( - <UnifiedLogsBanner - variant="utility" - className="mx-4 mt-4" - onSwitchBack={handleGoBackToOldLogs} - /> - )} + <UnifiedLogsBanner /> <div className="flex-1 p-2 sm:overflow-y-scroll"> <DataTableFilterControls dateRangeDisabled={dateRangeDisabled} /> - {afterFilters} <FeaturePreviewSidebarPanel className="mx-2 my-4" title="Capture your logs" @@ -93,9 +74,7 @@ export function FilterSideBar({ <div className="flex items-center gap-4"> {LOG_DRAIN_TYPES.filter((t) => ['datadog', 'sentry', 'webhook', 'loki'].includes(t.value) - ).map((type) => - React.cloneElement(type.icon, { height: 20, width: 20, key: type.value }) - )} + ).map((type) => cloneElement(type.icon, { height: 20, width: 20, key: type.value }))} </div> } actions={ diff --git a/apps/studio/components/ui/DataTable/LiveRow.tsx b/apps/studio/components/ui/DataTable/LiveRow.tsx deleted file mode 100644 index 1b1305dd7ac..00000000000 --- a/apps/studio/components/ui/DataTable/LiveRow.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { TableCell, TableRow } from 'ui' - -export function LiveRow({ colSpan }: { colSpan: number }) { - return ( - <TableRow> - <TableCell className="relative w-(--header-level-size) min-w-(--header-level-size) max-w-(--header-level-size) border-b border-r border-t border-info"> - <div className="flex items-center justify-center"> - <span className="absolute inline-flex h-2.5 w-2.5 animate-ping rounded-full bg-brand opacity-75"></span> - <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-brand"></span> - </div> - </TableCell> - <TableCell colSpan={colSpan} className="border-b border-r border-t text-foreground"> - Live Mode - </TableCell> - </TableRow> - ) -} diff --git a/apps/studio/components/ui/DateRangePicker.tsx b/apps/studio/components/ui/DateRangePicker.tsx index 9f981512456..3ed8f71000b 100644 --- a/apps/studio/components/ui/DateRangePicker.tsx +++ b/apps/studio/components/ui/DateRangePicker.tsx @@ -251,5 +251,3 @@ export const DateRangePicker = ({ </DropdownMenu> ) } - -export default DateRangePicker diff --git a/apps/studio/components/ui/DiffEditor.tsx b/apps/studio/components/ui/DiffEditor.tsx index d348ae2d0f8..a8777fd034d 100644 --- a/apps/studio/components/ui/DiffEditor.tsx +++ b/apps/studio/components/ui/DiffEditor.tsx @@ -25,6 +25,7 @@ const DEFAULT_OPTIONS: monacoEditor.IStandaloneDiffEditorConstructionOptions = { lineNumbersMinChars: 3, scrollBeyondLastLine: false, renderSideBySide: false, + renderGutterMenu: false, padding: { top: 4 }, } diff --git a/apps/studio/components/ui/DocsButton.tsx b/apps/studio/components/ui/DocsButton.tsx index c4a0df06575..9a3de4e8e9e 100644 --- a/apps/studio/components/ui/DocsButton.tsx +++ b/apps/studio/components/ui/DocsButton.tsx @@ -5,9 +5,10 @@ interface DocsButtonProps { href: string abbrev?: boolean className?: string + topic?: string } -export const DocsButton = ({ href, abbrev = true, className }: DocsButtonProps) => { +export const DocsButton = ({ href, abbrev = true, className, topic }: DocsButtonProps) => { return ( <Button asChild @@ -16,7 +17,12 @@ export const DocsButton = ({ href, abbrev = true, className }: DocsButtonProps) icon={<BookOpen />} onClick={(e) => e.stopPropagation()} > - <a target="_blank" rel="noopener noreferrer" href={href}> + <a + target="_blank" + rel="noopener noreferrer" + href={href} + aria-label={topic ? `${topic} documentation (opens in new tab)` : undefined} + > {abbrev ? 'Docs' : 'Documentation'} </a> </Button> diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index 807557c5f0f..8a557c2b082 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -50,7 +50,7 @@ import { suffixWithLimit, } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions' -import Results from '@/components/interfaces/SQLEditor/UtilityPanel/Results' +import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results' import { SqlRunButton } from '@/components/interfaces/SQLEditor/UtilityPanel/RunButton' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useContentIdQuery } from '@/data/content/content-id-query' @@ -66,7 +66,7 @@ import { editorPanelState, useEditorPanelStateSnapshot } from '@/state/editor-pa import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' export const EditorPanel = () => { const { @@ -274,7 +274,7 @@ export const EditorPanel = () => { } return ( - <div className="flex h-full flex-col bg-background"> + <div className="flex h-full flex-col bg-card"> <div className="border-b border-b-muted flex items-center justify-between gap-x-4 pl-4 pr-3 h-(--header-height)"> {isEditingTitle ? ( <input @@ -482,7 +482,7 @@ export const EditorPanel = () => { </div> <div className="flex-1 overflow-hidden flex flex-col h-full"> - <div className="flex-1 min-h-0 relative [&_.monaco-editor]:!bg [&_.monaco-editor_.margin]:!bg [&_.monaco-editor_.monaco-editor-background]:!bg"> + <div className="flex-1 min-h-0 relative [&_.monaco-editor]:!bg-card [&_.monaco-editor_.margin]:!bg-card [&_.monaco-editor_.monaco-editor-background]:!bg-card"> <AIEditor autoFocus language="pgsql" @@ -507,7 +507,7 @@ export const EditorPanel = () => { wordWrap: 'on', lineNumbers: 'on', folding: false, - padding: { top: 16 }, + padding: { top: 12 }, lineNumbersMinChars: 3, }} executeQuery={onExecuteSql} @@ -516,7 +516,6 @@ export const EditorPanel = () => { openAIAssistantShortcutEnabled={isAIAssistantHotkeyEnabled} /> </div> - {error !== undefined && ( <div className="shrink-0"> <Admonition @@ -539,7 +538,6 @@ export const EditorPanel = () => { /> </div> )} - {showWarning && ( <SqlWarningAdmonition className="border-t" @@ -557,7 +555,6 @@ export const EditorPanel = () => { }} /> )} - {results !== undefined && results.length > 0 && ( <div className={cn( @@ -593,7 +590,6 @@ export const EditorPanel = () => { </p> </div> )} - <div className="relative shrink-0 flex items-center gap-2 justify-end px-5 py-4 w-full border-t"> {(isUpserting || saveStatus !== 'idle') && ( <div @@ -684,5 +680,3 @@ export const EditorPanel = () => { </div> ) } - -export default EditorPanel diff --git a/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx index be3593e61ae..f5e37a2d175 100644 --- a/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx +++ b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx @@ -3,7 +3,7 @@ import { safeLocalStorage, safeSessionStorage } from 'common' import { ExternalLink } from 'lucide-react' import { useRouter } from 'next/router' import { Button, cn } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import CopyButton from '../CopyButton' import { InlineLinkClassName } from '../InlineLink' diff --git a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts b/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts index 716c40289db..174944f1af3 100644 --- a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts +++ b/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts @@ -38,7 +38,17 @@ export const isBinaryFile = (fileName: string): boolean => { return binaryExtensions.includes(extension || '') } -export const getLanguageFromFileName = (fileName: string): string => { +type EditorLanguage = + | 'typescript' + | 'javascript' + | 'json' + | 'html' + | 'css' + | 'markdown' + | 'csv' + | 'plaintext' + +export const getLanguageFromFileName = (fileName: string): EditorLanguage => { const extension = fileName.split('.').pop()?.toLowerCase() switch (extension) { case 'ts': diff --git a/apps/studio/components/ui/Forms/Form.constants.ts b/apps/studio/components/ui/Forms/Form.constants.ts deleted file mode 100644 index e3910fabb06..00000000000 --- a/apps/studio/components/ui/Forms/Form.constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as z from 'zod' - -export const StringNumberOrNull = z - .string() - .transform((v) => (v === '' ? null : v)) - .nullable() - .refine((value) => value === null || !isNaN(Number(value)), { - message: 'Invalid number', - }) - .transform((value) => (value === null ? null : Number(value))) - -/** - * [Joshen] After wrangling with RHF I think this is the easiest way to handle nullable number fields - * - Declare the field normally as you would in the zod form schema (e.g field: z.number().nullable()) - * - In the InputField, add a form.register call `{...form.register('field_name', { setValueAs: setValueAsNullableNumber })}` - */ -export const setValueAsNullableNumber = (v: any) => (v === '' || v === null ? null : parseInt(v)) diff --git a/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState.tsx b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState.tsx new file mode 100644 index 00000000000..f1cc7df91b9 --- /dev/null +++ b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState.tsx @@ -0,0 +1,32 @@ +import type { LucideIcon } from 'lucide-react' +import { Server } from 'lucide-react' +import type { ReactNode } from 'react' +import { cn } from 'ui' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' + +const DEFAULT_TITLE = 'This feature is unavailable on High Availability projects' +const DEFAULT_DESCRIPTION = + "We're working to bring this feature to High Availability projects. Contact support if this is blocking your work." + +interface HighAvailabilityDisabledEmptyStateProps { + title?: string + description?: ReactNode + icon?: LucideIcon | ReactNode + className?: string +} + +export function HighAvailabilityDisabledEmptyState({ + title = DEFAULT_TITLE, + description = DEFAULT_DESCRIPTION, + icon = Server, + className, +}: HighAvailabilityDisabledEmptyStateProps) { + return ( + <EmptyStatePresentational + icon={icon} + title={title} + description={description} + className={cn('w-full max-w-md mx-auto', className)} + /> + ) +} diff --git a/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx new file mode 100644 index 00000000000..50188ff9856 --- /dev/null +++ b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { Admonition } from 'ui-patterns/admonition' + +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' + +const DEFAULT_TITLE = 'This feature is unavailable on High Availability projects' +const DEFAULT_DESCRIPTION = + "We're working to bring this feature to High Availability projects. Contact support if this is blocking your work." + +interface HighAvailabilityDisabledSectionNoticeProps { + title?: string + description?: ReactNode +} + +export function HighAvailabilityDisabledSectionNotice({ + title = DEFAULT_TITLE, + description = DEFAULT_DESCRIPTION, +}: HighAvailabilityDisabledSectionNoticeProps) { + const { isHighAvailability } = useHighAvailability() + + if (!isHighAvailability) return null + + return ( + <Admonition type="default" title={title}> + <p className="text-sm text-foreground-light">{description}</p> + </Admonition> + ) +} diff --git a/apps/studio/components/ui/HighQueryCost.tsx b/apps/studio/components/ui/HighQueryCost.tsx index ceb63c37c43..4d9432f08ef 100644 --- a/apps/studio/components/ui/HighQueryCost.tsx +++ b/apps/studio/components/ui/HighQueryCost.tsx @@ -14,7 +14,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { DocsButton } from './DocsButton' import { InlineLinkClassName } from './InlineLink' diff --git a/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx b/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx index 1a2f195b8b0..26e7b376dc1 100644 --- a/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx +++ b/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx @@ -1,3 +1,4 @@ +import { useFlag, useParams } from 'common' import { BookOpen, Check, ChevronsUpDown, Copy, ExternalLink, List, X } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' @@ -23,7 +24,12 @@ import { import { DocsButton } from '../DocsButton' import { LOGS_EXPLORER_DOCS_URL } from '@/components/interfaces/Settings/Logs/Logs.constants' +import { + otelFieldsFromKeys, + toOtelFieldSchemas, +} from '@/components/interfaces/Settings/Logs/Logs.fieldReference' import Table from '@/components/to-be-cleaned/Table' +import { useOtelLogKeysQuery } from '@/data/logs/otel-log-keys-query' import { DOCS_URL } from '@/lib/constants' export interface LogsExplorerHeaderProps { @@ -33,7 +39,16 @@ export interface LogsExplorerHeaderProps { const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => { const [showReference, setShowReference] = useState(false) const [open, setOpen] = useState(false) - const [selectedSchema, setSelectedSchema] = useState(logConstants.schemas[0]) + const useOtel = useFlag('otelLegacyLogs') + const schemas = useOtel ? toOtelFieldSchemas(logConstants.schemas) : logConstants.schemas + const [selectedRef, setSelectedRef] = useState(schemas[0]?.reference) + const selectedSchema = schemas.find((s) => s.reference === selectedRef) ?? schemas[0] + + const { ref: projectRef } = useParams() + const { data: discoveredKeys, isPending: isLoadingKeys } = useOtelLogKeysQuery( + { projectRef, source: selectedRef }, + { enabled: useOtel && showReference } + ) return ( <div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all pb-6 justify-between"> @@ -79,24 +94,33 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => { > <SidePanel.Content> <div className="pt-4 pb-2 space-y-1"> - <p className="text-sm"> - The following table shows all the available paths that can be queried from each - respective source. Do note that to access nested keys, you would need to perform the - necessary{' '} - <Link - href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`} - target="_blank" - rel="noreferrer" - className="text-brand" - > - unnesting joins - <ExternalLink - size={14} - className="ml-1 inline translate-y-[-2px]" - strokeWidth={1.5} - /> - </Link> - </p> + {useOtel ? ( + <p className="text-sm"> + The following table shows the fields available on each source. Nested fields live + in the <code className="text-xs">log_attributes</code> map and are read with{' '} + <code className="text-xs">log_attributes['key']</code> — no unnesting joins + needed. + </p> + ) : ( + <p className="text-sm"> + The following table shows all the available paths that can be queried from each + respective source. Do note that to access nested keys, you would need to perform + the necessary{' '} + <Link + href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`} + target="_blank" + rel="noreferrer" + className="text-brand" + > + unnesting joins + <ExternalLink + size={14} + className="ml-1 inline translate-y-[-2px]" + strokeWidth={1.5} + /> + </Link> + </p> + )} </div> </SidePanel.Content> <SidePanel.Separator /> @@ -120,19 +144,21 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => { <CommandList> <CommandEmpty>No source found.</CommandEmpty> <CommandGroup> - {logConstants.schemas.map((schema) => ( + {schemas.map((schema) => ( <CommandItem key={schema.reference} value={schema.reference} onSelect={() => { - setSelectedSchema(schema) + setSelectedRef(schema.reference) setOpen(false) }} > <Check className={cn( 'mr-2 h-4 w-4', - selectedSchema === schema ? 'opacity-100' : 'opacity-0' + selectedSchema?.reference === schema.reference + ? 'opacity-100' + : 'opacity-0' )} /> {schema.name} @@ -143,19 +169,26 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => { </Command> </PopoverContent> </Popover> - <Table - head={[ - <Table.th className="text-xs p-2!" key="path"> - Path - </Table.th>, - <Table.th key="type" className="text-xs p-2!"> - Type - </Table.th>, - ]} - body={selectedSchema.fields.map((field) => ( - <Field key={field.path} field={field} /> - ))} - /> + {useOtel && isLoadingKeys ? ( + <p className="text-sm text-foreground-light py-2">Loading fields…</p> + ) : ( + <Table + head={[ + <Table.th className="text-xs p-2!" key="path"> + Path + </Table.th>, + <Table.th key="type" className="text-xs p-2!"> + Type + </Table.th>, + ]} + body={(() => { + const fields = useOtel + ? otelFieldsFromKeys(discoveredKeys ?? []) + : selectedSchema.fields + return fields.map((field) => <Field key={field.path} field={field} />) + })()} + /> + )} </div> </SidePanel> </div> diff --git a/apps/studio/components/ui/NoPermission.tsx b/apps/studio/components/ui/NoPermission.tsx index c4e379a9fc3..90d8c3c32bd 100644 --- a/apps/studio/components/ui/NoPermission.tsx +++ b/apps/studio/components/ui/NoPermission.tsx @@ -6,7 +6,7 @@ interface NoPermissionProps { } export const NoPermission = ({ resourceText, isFullPage = false }: NoPermissionProps) => { - const NoPermissionMessage = () => ( + const NoPermissionMessage = ( <Admonition type="warning" title={`You need additional permissions to ${resourceText}`} @@ -17,14 +17,10 @@ export const NoPermission = ({ resourceText, isFullPage = false }: NoPermissionP if (isFullPage) { return ( <div className="flex h-full items-center justify-center"> - <div className="max-w-lg"> - <NoPermissionMessage /> - </div> + <div className="max-w-lg">{NoPermissionMessage}</div> </div> ) } else { - return <NoPermissionMessage /> + return NoPermissionMessage } } - -export default NoPermission diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index 4612f135103..6387e97d8b6 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -103,9 +103,9 @@ const PanelNotice = forwardRef< className="absolute inset-0 mt-[-5px]" style={{ backgroundImage: ` - linear-gradient(to right, hsl(var(--background-200)/1) 0%, hsl(var(--background-200)/1) 30%, hsl(var(--background-200)/0) 100%), - linear-gradient(to right, hsl(var(--border-default)/0.33) 1px, transparent 1px), - linear-gradient(to bottom, hsl(var(--border-default)/0.33) 1px, transparent 1px) + linear-gradient(to right, oklch(from var(--background-200) l c h / 1) 0%, oklch(from var(--background-200) l c h / 1) 30%, oklch(from var(--background-200) l c h / 0) 100%), + linear-gradient(to right, oklch(from var(--border-default) l c h / 0.33) 1px, transparent 1px), + linear-gradient(to bottom, oklch(from var(--border-default) l c h / 0.33) 1px, transparent 1px) `, backgroundSize: '100% 100%, 15px 15px, 15px 15px', backgroundPosition: '0 0, 0 0, 0 0', diff --git a/apps/studio/components/ui/PartnerIcon.tsx b/apps/studio/components/ui/PartnerIcon.tsx index a7b60581a39..dd1c4107008 100644 --- a/apps/studio/components/ui/PartnerIcon.tsx +++ b/apps/studio/components/ui/PartnerIcon.tsx @@ -53,7 +53,10 @@ function getPartnerIcon( fill="none" xmlns="http://www.w3.org/2000/svg" > - <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="hsl(var(--foreground-default) / 1)" /> + <path + d="M37.5274 0L75.0548 65H0L37.5274 0Z" + fill="oklch(from var(--foreground-default) l c h / 1)" + /> </svg> ) case MANAGED_BY.AWS_MARKETPLACE: @@ -68,7 +71,7 @@ function getPartnerIcon( fill="none" xmlns="http://www.w3.org/2000/svg" > - <g fill="hsl(var(--foreground-default) / 1)" transform="scale(1.25)"> + <g fill="oklch(from var(--foreground-default) l c h / 1)" transform="scale(1.25)"> <path d="M4.51 7.687c0 .197.02.357.058.475.042.117.096.245.17.384a.233.233 0 01.037.123c0 .053-.032.107-.1.16l-.336.224a.255.255 0 01-.138.048c-.054 0-.107-.026-.16-.074a1.652 1.652 0 01-.192-.251 4.137 4.137 0 01-.164-.315c-.416.491-.937.737-1.565.737-.447 0-.804-.129-1.064-.385-.261-.256-.394-.598-.394-1.025 0-.454.16-.822.484-1.1.325-.278.756-.416 1.304-.416.18 0 .367.016.564.042.197.027.4.07.612.118v-.39c0-.406-.085-.689-.25-.854-.17-.166-.458-.246-.868-.246-.186 0-.377.022-.574.07a4.23 4.23 0 00-.575.181 1.525 1.525 0 01-.186.07.326.326 0 01-.085.016c-.075 0-.112-.054-.112-.166v-.262c0-.085.01-.15.037-.186a.399.399 0 01.15-.113c.185-.096.409-.176.67-.24.26-.07.537-.101.83-.101.633 0 1.096.144 1.394.432.293.288.442.726.442 1.314v1.73h.01zm-2.161.811c.175 0 .356-.032.548-.096.192-.064.362-.182.505-.342a.848.848 0 00.181-.341c.032-.129.054-.283.054-.465V7.03a4.43 4.43 0 00-.49-.09 3.996 3.996 0 00-.5-.033c-.357 0-.617.07-.793.214-.176.144-.26.347-.26.614 0 .25.063.437.196.566.128.133.314.197.559.197zm4.273.577c-.096 0-.16-.016-.202-.054-.043-.032-.08-.106-.112-.208l-1.25-4.127a.938.938 0 01-.048-.214c0-.085.042-.133.127-.133h.522c.1 0 .17.016.207.053.043.032.075.107.107.208l.894 3.535.83-3.535c.026-.106.058-.176.101-.208a.365.365 0 01.213-.053h.426c.1 0 .17.016.212.053.043.032.08.107.102.208l.84 3.578.92-3.578a.459.459 0 01.107-.208.347.347 0 01.208-.053h.495c.085 0 .133.043.133.133 0 .027-.006.054-.01.086a.768.768 0 01-.038.133l-1.283 4.127c-.031.107-.069.177-.111.209a.34.34 0 01-.203.053h-.457c-.101 0-.17-.016-.213-.053-.043-.038-.08-.107-.101-.214L8.213 5.37l-.82 3.439c-.026.107-.058.176-.1.213-.043.038-.118.054-.213.054h-.458zm6.838.144a3.51 3.51 0 01-.82-.096c-.266-.064-.473-.134-.612-.214-.085-.048-.143-.101-.165-.15a.38.38 0 01-.031-.149v-.272c0-.112.042-.166.122-.166a.3.3 0 01.096.016c.032.011.08.032.133.054.18.08.378.144.585.187.213.042.42.064.633.064.336 0 .596-.059.777-.176a.575.575 0 00.277-.508.52.52 0 00-.144-.373c-.095-.102-.276-.193-.537-.278l-.772-.24c-.388-.123-.676-.305-.851-.545a1.275 1.275 0 01-.266-.774c0-.224.048-.422.143-.593.096-.17.224-.32.384-.438.16-.122.34-.213.553-.277.213-.064.436-.091.67-.091.118 0 .24.005.357.021.122.016.234.038.346.06.106.026.208.052.303.085.096.032.17.064.224.096a.461.461 0 01.16.133.289.289 0 01.047.176v.251c0 .112-.042.171-.122.171a.552.552 0 01-.202-.064 2.428 2.428 0 00-1.022-.208c-.303 0-.543.048-.708.15-.165.1-.25.256-.25.475 0 .149.053.277.16.379.106.101.303.202.585.293l.756.24c.383.123.66.294.825.513.165.219.244.47.244.748 0 .23-.047.437-.138.619a1.435 1.435 0 01-.388.47c-.165.133-.362.23-.591.299-.24.075-.49.112-.761.112z" /> <path diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts index 68343a88118..1afca9a16dd 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import { useRef } from 'react' -import useLogsQuery from '@/hooks/analytics/useLogsQuery' +import { useLogsQuery } from '@/hooks/analytics/useLogsQuery' export function useLastUsedAPIKeysLogQuery({ projectRef, diff --git a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx index b64761076ba..1189824467c 100644 --- a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx +++ b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx @@ -3,7 +3,7 @@ import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events' import { useParams } from 'common' import dayjs from 'dayjs' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { InlineLink } from './InlineLink' import { SupportLink } from '@/components/interfaces/Support/SupportLink' diff --git a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx index 8e70d54fcfa..2f620af18ec 100644 --- a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx +++ b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx @@ -12,13 +12,13 @@ import { import { ButtonTooltip } from '../ButtonTooltip' import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' -import useNewQuery from '@/components/interfaces/SQLEditor/hooks' +import { useNewQuery } from '@/components/interfaces/SQLEditor/hooks' import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useTrack } from '@/lib/telemetry/track' import { editorPanelState } from '@/state/editor-panel-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useSqlEditorDiffRequestSnapshot } from '@/state/sql-editor/sql-editor-diff-request' interface EditQueryButtonProps { id?: string @@ -38,7 +38,7 @@ export const EditQueryButton = ({ const router = useRouter() const { newQuery } = useNewQuery() - const sqlEditorSnap = useSqlEditorV2StateSnapshot() + const diffRequest = useSqlEditorDiffRequestSnapshot() const { closeSidebar, openSidebar } = useSidebarManagerSnapshot() const isInSQLEditor = router.pathname.includes('/sql') @@ -102,12 +102,10 @@ export const EditQueryButton = ({ </DropdownMenuTrigger> {!!sql && ( <DropdownMenuContent className="w-36"> - <DropdownMenuItem onClick={() => sqlEditorSnap.setDiffContent(sql, DiffType.Addition)}> + <DropdownMenuItem onClick={() => diffRequest.requestDiff(sql, DiffType.Addition)}> Insert code </DropdownMenuItem> - <DropdownMenuItem - onClick={() => sqlEditorSnap.setDiffContent(sql, DiffType.Modification)} - > + <DropdownMenuItem onClick={() => diffRequest.requestDiff(sql, DiffType.Modification)}> Replace code </DropdownMenuItem> <DropdownMenuItem onClick={() => newQuery(sql, title)}> diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index c586fb79f6a..97907d4edb3 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -21,7 +21,7 @@ import { } from './QueryBlock.utils' import { ReportBlockContainer } from '@/components/interfaces/Reports/ReportBlock/ReportBlockContainer' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' -import Results from '@/components/interfaces/SQLEditor/UtilityPanel/Results' +import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results' export const DEFAULT_CHART_CONFIG: ChartConfig = { type: 'bar', @@ -348,12 +348,12 @@ export const QueryBlock = ({ ) } /> - <Bar radius={1} dataKey={yKey} fill="hsl(var(--chart-1))"> + <Bar radius={1} dataKey={yKey} fill="var(--chart-1)"> {chartData?.map((_: any, index: number) => ( <Cell key={`cell-${index}`} className="transition-all duration-100" - fill="hsl(var(--chart-1))" + fill="var(--chart-1)" opacity={focusDataIndex === undefined || focusDataIndex === index ? 1 : 0.4} enableBackground={12} /> diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts b/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts index 7a7ffbb9708..342f5b17a60 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts @@ -1,4 +1,4 @@ -import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import type { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' export const checkHasNonPositiveValues = (data: Record<string, unknown>[], key: string): boolean => data.some((row) => (row[key] as number) <= 0) @@ -47,16 +47,19 @@ export const formatLogTick = (value: number): string => { return value.toLocaleString() } -export const getCumulativeResults = (results: { rows: any[] }, config: ChartConfig) => { +export const getCumulativeResults = (results: { rows: readonly any[] }, config: ChartConfig) => { if (!results?.rows?.length) { return [] } const cumulativeResults = results.rows.reduce((acc, row) => { const prev = acc[acc.length - 1] || {} + // Coerce to Number before adding: Postgres returns `bigint`, `numeric`, + // `money` and `count(*)` columns as strings, so a bare `+` would + // concatenate (e.g. "10" + "20" -> "1020") instead of summing. const next = { ...row, - [config.yKey]: (prev[config.yKey] || 0) + row[config.yKey], + [config.yKey]: (Number(prev[config.yKey]) || 0) + (Number(row[config.yKey]) || 0), } return [...acc, next] }, []) diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index e4b50fd9086..a04328e0367 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -229,5 +229,3 @@ export const SchemaSelector = forwardRef<HTMLDivElement, SchemaSelectorProps>( ) SchemaSelector.displayName = 'SchemaSelector' - -export default SchemaSelector diff --git a/apps/studio/components/ui/SparkBar.tsx b/apps/studio/components/ui/SparkBar.tsx index dafbdbf8327..b283adc67d3 100644 --- a/apps/studio/components/ui/SparkBar.tsx +++ b/apps/studio/components/ui/SparkBar.tsx @@ -75,5 +75,3 @@ export const SparkBar = ({ ) } } - -export default SparkBar diff --git a/apps/studio/components/ui/SqlEditor.tsx b/apps/studio/components/ui/SqlEditor.tsx deleted file mode 100644 index 9a8fb6d2711..00000000000 --- a/apps/studio/components/ui/SqlEditor.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import Editor, { OnChange, useMonaco } from '@monaco-editor/react' -import { noop } from 'lodash' -import { useEffect, useRef } from 'react' -import { LogoLoader } from 'ui' - -import { formatSql } from '@/lib/formatSql' - -// [Joshen] We should deprecate this and use CodeEditor instead - -interface SqlEditorProps { - contextmenu?: boolean - defaultValue?: string - language?: string - onInputChange?: OnChange - queryId?: string - readOnly?: boolean -} - -/** - * @deprecated Use CodeEditor instead - */ -const SqlEditor = ({ - queryId, - language = 'pgsql', - defaultValue = '', - readOnly = false, - contextmenu = true, - onInputChange = noop, -}: SqlEditorProps) => { - const monaco = useMonaco() - const editorRef = useRef<any>(null) - - useEffect(() => { - if (monaco) { - // Enable pgsql format - const formatprovider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { - async provideDocumentFormattingEdits(model: any) { - const value = model.getValue() - const formatted = formatSql(value) - return [ - { - range: model.getFullModelRange(), - text: formatted, - }, - ] - }, - }) - - return () => { - formatprovider.dispose() - } - } - }, [monaco]) - - useEffect(() => { - if (editorRef.current) { - // add margin above first line - editorRef.current?.changeViewZones((accessor: any) => { - accessor.addZone({ - afterLineNumber: 0, - heightInPx: 4, - domNode: document.createElement('div'), - }) - }) - } - }, [queryId]) - - const onMount = (editor: any, _monaco: any) => { - editorRef.current = editor - - // Add margin above first line - editor.changeViewZones((accessor: any) => { - accessor.addZone({ - afterLineNumber: 0, - heightInPx: 4, - domNode: document.createElement('div'), - }) - }) - } - - return ( - <Editor - className="monaco-editor" - theme="supabase" - defaultLanguage={language} - defaultValue={defaultValue} - path={queryId} - loading={<LogoLoader />} - options={{ - readOnly, - tabSize: 2, - fontSize: 13, - minimap: { - enabled: false, - }, - wordWrap: 'on', - fixedOverflowWidgets: true, - contextmenu: contextmenu, - }} - onMount={onMount} - onChange={onInputChange} - /> - ) -} - -export default SqlEditor diff --git a/apps/studio/components/ui/SqlWarningAdmonition.tsx b/apps/studio/components/ui/SqlWarningAdmonition.tsx index 6c89532ab16..d8cb41f5324 100644 --- a/apps/studio/components/ui/SqlWarningAdmonition.tsx +++ b/apps/studio/components/ui/SqlWarningAdmonition.tsx @@ -1,5 +1,5 @@ import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' export interface SqlWarningAdmonitionProps { warningType: 'hasWriteOperation' | 'hasUnknownFunctions' diff --git a/apps/studio/components/ui/UnknownInterface.tsx b/apps/studio/components/ui/UnknownInterface.tsx index ff7ba4ef08d..fcbf30d68f8 100644 --- a/apps/studio/components/ui/UnknownInterface.tsx +++ b/apps/studio/components/ui/UnknownInterface.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import { Button, cn } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' export const UnknownInterface = ({ urlBack, diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index c7e8b5ebf28..b8e6b0f81a0 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react' import { cn } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { DocsButton } from './DocsButton' import { UpgradePlanButton } from './UpgradePlanButton' diff --git a/apps/studio/data/analytics/functions-req-stats-query.ts b/apps/studio/data/analytics/functions-req-stats-query.ts deleted file mode 100644 index 88f04586fbe..00000000000 --- a/apps/studio/data/analytics/functions-req-stats-query.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { operations } from 'api-types' - -import { analyticsKeys } from './keys' -import { get, handleError } from '@/data/fetchers' -import { UseCustomQueryOptions } from '@/types' - -export type FunctionsReqStatsVariables = { - projectRef?: string - functionId?: string - interval?: operations['FunctionsLogsController_getRequestStats']['parameters']['query']['interval'] -} - -export type FunctionsReqStatsResponse = any - -export async function getFunctionsReqStats( - { projectRef, functionId, interval }: FunctionsReqStatsVariables, - signal?: AbortSignal -) { - if (!projectRef) { - throw new Error('projectRef is required') - } - if (!functionId) { - throw new Error('functionId is required') - } - if (!interval) { - throw new Error('interval is required') - } - - const { data, error } = await get( - '/platform/projects/{ref}/analytics/endpoints/functions.req-stats', - { - params: { - path: { - ref: projectRef, - }, - query: { - function_id: functionId, - interval, - }, - }, - signal, - } - ) - - if (error) handleError(error) - - return data -} - -export type FunctionsReqStatsData = Awaited<ReturnType<typeof getFunctionsReqStats>> -export type FunctionsReqStatsError = unknown - -export const useFunctionsReqStatsQuery = <TData = FunctionsReqStatsData>( - { projectRef, functionId, interval }: FunctionsReqStatsVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions<FunctionsReqStatsData, FunctionsReqStatsError, TData> = {} -) => - useQuery<FunctionsReqStatsData, FunctionsReqStatsError, TData>({ - queryKey: analyticsKeys.functionsReqStats(projectRef, { functionId, interval }), - queryFn: ({ signal }) => getFunctionsReqStats({ projectRef, functionId, interval }, signal), - enabled: - enabled && - typeof projectRef !== 'undefined' && - typeof functionId !== 'undefined' && - typeof interval !== 'undefined', - ...options, - }) diff --git a/apps/studio/data/analytics/functions-resource-usage-query.ts b/apps/studio/data/analytics/functions-resource-usage-query.ts deleted file mode 100644 index 44622300a9d..00000000000 --- a/apps/studio/data/analytics/functions-resource-usage-query.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { operations } from 'api-types' - -import { analyticsKeys } from './keys' -import { get, handleError } from '@/data/fetchers' -import { UseCustomQueryOptions } from '@/types' - -export type FunctionsResourceUsageVariables = { - projectRef?: string - functionId?: string - interval?: operations['FunctionsLogsController_getRequestStats']['parameters']['query']['interval'] -} - -export type FunctionsResourceUsageResponse = any - -export async function getFunctionsResourceUsage( - { projectRef, functionId, interval }: FunctionsResourceUsageVariables, - signal?: AbortSignal -) { - if (!projectRef) { - throw new Error('projectRef is required') - } - if (!functionId) { - throw new Error('functionId is required') - } - if (!interval) { - throw new Error('interval is required') - } - - const { data, error } = await get( - '/platform/projects/{ref}/analytics/endpoints/functions.resource-usage', - { - params: { - path: { - ref: projectRef, - }, - query: { - function_id: functionId, - interval, - }, - }, - signal, - } - ) - - if (error) handleError(error) - - return data -} - -export type FunctionsResourceUsageData = Awaited<ReturnType<typeof getFunctionsResourceUsage>> -export type FunctionsResourceUsageError = unknown - -export const useFunctionsResourceUsageQuery = <TData = FunctionsResourceUsageData>( - { projectRef, functionId, interval }: FunctionsResourceUsageVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions<FunctionsResourceUsageData, FunctionsResourceUsageError, TData> = {} -) => - useQuery<FunctionsResourceUsageData, FunctionsResourceUsageError, TData>({ - queryKey: analyticsKeys.functionsResourceUsage(projectRef, { functionId, interval }), - queryFn: ({ signal }) => - getFunctionsResourceUsage({ projectRef, functionId, interval }, signal), - enabled: - enabled && - typeof projectRef !== 'undefined' && - typeof functionId !== 'undefined' && - typeof interval !== 'undefined', - ...options, - }) diff --git a/apps/studio/data/analytics/infra-monitoring-queries.test.ts b/apps/studio/data/analytics/infra-monitoring-queries.test.ts index 0720e41806f..b8be88aa2f3 100644 --- a/apps/studio/data/analytics/infra-monitoring-queries.test.ts +++ b/apps/studio/data/analytics/infra-monitoring-queries.test.ts @@ -1,11 +1,7 @@ import dayjs from 'dayjs' import { describe, expect, it } from 'vitest' -import { - aggregate1MinTo2Min, - mapMultiResponseToAnalyticsData, - mapResponseToAnalyticsData, -} from './infra-monitoring-queries' +import { aggregate1MinTo2Min, mapResponseToAnalyticsData } from './infra-monitoring-queries' import type { InfraMonitoringMultiData, InfraMonitoringMultiResponse, @@ -166,13 +162,6 @@ describe('mapResponseToAnalyticsData', () => { }) }) -// Test deprecated alias still works -describe('mapMultiResponseToAnalyticsData (deprecated)', () => { - it('is an alias for mapResponseToAnalyticsData', () => { - expect(mapMultiResponseToAnalyticsData).toBe(mapResponseToAnalyticsData) - }) -}) - describe('2m interval aggregation', () => { it('aggregates 1m data points into 2m buckets for multi-attribute response', () => { const mock1MinData: InfraMonitoringMultiResponse = { diff --git a/apps/studio/data/analytics/infra-monitoring-queries.ts b/apps/studio/data/analytics/infra-monitoring-queries.ts index f3f812dcebd..6c58dc24906 100644 --- a/apps/studio/data/analytics/infra-monitoring-queries.ts +++ b/apps/studio/data/analytics/infra-monitoring-queries.ts @@ -221,6 +221,3 @@ export function mapResponseToAnalyticsData( }, } } - -/** @deprecated Use mapResponseToAnalyticsData instead */ -export const mapMultiResponseToAnalyticsData = mapResponseToAnalyticsData diff --git a/apps/studio/data/api-keys/api-key-id-update-mutation.ts b/apps/studio/data/api-keys/api-key-id-update-mutation.ts deleted file mode 100644 index 108e43667ad..00000000000 --- a/apps/studio/data/api-keys/api-key-id-update-mutation.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - -import { apiKeysKeys } from './keys' -import { handleError, patch } from '@/data/fetchers' -import type { ResponseError, UseCustomMutationOptions } from '@/types' - -export interface UpdateAPIKeybyIdVariables { - projectRef?: string - id?: string - description?: string -} - -export async function updateAPIKeysById( - { projectRef, id, description }: UpdateAPIKeybyIdVariables, - signal?: AbortSignal -) { - if (typeof projectRef === 'undefined') throw new Error('projectRef is required') - if (typeof id === 'undefined') throw new Error('API key ID is required') - - const { data, error } = await patch('/v1/projects/{ref}/api-keys/{id}', { - params: { - path: { ref: projectRef, id }, - query: { reveal: false }, - }, - body: { - description: description, - }, - signal, - }) - - if (error) { - handleError(error) - } - - return data -} - -export type ResourceUpdateData = Awaited<ReturnType<typeof updateAPIKeysById>> - -export const useResourceUpdateMutation = ({ - onSuccess, - onError, - ...options -}: Omit< - UseCustomMutationOptions<ResourceUpdateData, ResponseError, UpdateAPIKeybyIdVariables>, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - - return useMutation<ResourceUpdateData, ResponseError, UpdateAPIKeybyIdVariables>({ - mutationFn: (vars) => updateAPIKeysById(vars), - async onSuccess(data, variables, context) { - const { projectRef } = variables - - await queryClient.invalidateQueries({ queryKey: apiKeysKeys.list(projectRef) }) - - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to mutate: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - }) -} diff --git a/apps/studio/data/content/content-id-query.ts b/apps/studio/data/content/content-id-query.ts index df6a1f8ea8b..681e7c645ca 100644 --- a/apps/studio/data/content/content-id-query.ts +++ b/apps/studio/data/content/content-id-query.ts @@ -4,6 +4,7 @@ import { components } from 'api-types' import type { Content } from './content-query' import { remapSqlContentField } from './content-remap' import { contentKeys } from './keys' +import type { SnippetWithContent } from './sql-folders-query' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' @@ -33,6 +34,43 @@ export async function getContentById( export type ContentIdData = Awaited<ReturnType<typeof getContentById>> export type ContentIdError = ResponseError +// SQL-editor-specific fetch: the editor only ever loads SQL snippets, so the +// content body is typed as SQL content and the result is a SnippetWithContent +// (status 'saved') ready to drop into the store — no narrowing/casting at the +// call site. Reports etc. keep using the generic getContentById above. +export async function getSqlSnippetById( + { projectRef, id }: { projectRef?: string; id?: string }, + signal?: AbortSignal +): Promise<SnippetWithContent> { + if (typeof projectRef === 'undefined') throw new Error('projectRef is required') + if (typeof id === 'undefined') throw new Error('Content ID is required') + + const { data, error } = await get('/platform/projects/{ref}/content/item/{id}', { + params: { path: { ref: projectRef, id } }, + signal, + }) + + if (error) throw handleError(error) + const snippet = remapSqlContentField(data as unknown as Omit<SnippetWithContent, 'status'>) + return { ...snippet, status: 'saved' } +} + +export type SqlSnippetByIdData = Awaited<ReturnType<typeof getSqlSnippetById>> + +export const useSqlSnippetByIdQuery = <TData = SqlSnippetByIdData>( + { projectRef, id }: { projectRef?: string; id?: string }, + { + enabled = true, + ...options + }: UseCustomQueryOptions<SqlSnippetByIdData, ContentIdError, TData> = {} +) => + useQuery<SqlSnippetByIdData, ContentIdError, TData>({ + queryKey: contentKeys.resource(projectRef, id), + queryFn: ({ signal }) => getSqlSnippetById({ projectRef, id }, signal), + enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined', + ...options, + }) + export const useContentIdQuery = <TData = ContentIdData>( { projectRef, id }: { projectRef?: string; id?: string }, { enabled = true, ...options }: UseCustomQueryOptions<ContentIdData, ContentIdError, TData> = {} diff --git a/apps/studio/data/content/content-remap.test.ts b/apps/studio/data/content/content-remap.test.ts new file mode 100644 index 00000000000..c2fa305e6e2 --- /dev/null +++ b/apps/studio/data/content/content-remap.test.ts @@ -0,0 +1,103 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { describe, expect, it } from 'vitest' + +import { remapSqlContentField, remapSqlContentFields, unmapSqlContentField } from './content-remap' + +const SQL_SNIPPET = { + id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + type: 'sql' as const, + name: 'My query', + content: { + sql: 'SELECT 1', + content_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + schema_version: '1.0', + }, +} + +describe('remapSqlContentField', () => { + it('remaps content.sql to content.unchecked_sql for SQL snippets', () => { + const result = remapSqlContentField(SQL_SNIPPET) + + expect(result.content).toEqual({ + content_id: SQL_SNIPPET.content.content_id, + schema_version: '1.0', + unchecked_sql: untrustedSql('SELECT 1'), + }) + expect(result.content).not.toHaveProperty('sql') + }) + + it('leaves non-SQL content types unchanged', () => { + const report = { id: '1', type: 'report' as const, content: { foo: 'bar' } } + + expect(remapSqlContentField(report)).toBe(report) + }) + + it('leaves SQL snippets without a content field unchanged', () => { + const snippet = { id: '1', type: 'sql' as const, name: 'No content' } + + expect(remapSqlContentField(snippet)).toBe(snippet) + }) + + it('leaves SQL snippets whose content has no sql field unchanged', () => { + const snippet = { + id: '1', + type: 'sql' as const, + content: { content_id: '1', schema_version: '1.0', unchecked_sql: untrustedSql('x') }, + } + + expect(remapSqlContentField(snippet)).toBe(snippet) + }) +}) + +describe('remapSqlContentFields', () => { + it('remaps every SQL snippet in a list', () => { + const items = [SQL_SNIPPET, { id: '2', type: 'report' as const }] + + const result = remapSqlContentFields(items) + const remappedSql = result.find((item) => item.type === 'sql') + + expect(remappedSql && 'content' in remappedSql && remappedSql.content).toMatchObject({ + unchecked_sql: untrustedSql('SELECT 1'), + }) + expect(result[1]).toBe(items[1]) + }) +}) + +describe('unmapSqlContentField', () => { + it('remaps content.unchecked_sql back to content.sql for the API', () => { + const snippet = remapSqlContentField(SQL_SNIPPET) + + const result = unmapSqlContentField(snippet) + + expect(result.content).toEqual({ + content_id: SQL_SNIPPET.content.content_id, + schema_version: '1.0', + sql: untrustedSql('SELECT 1'), + }) + expect(result.content).not.toHaveProperty('unchecked_sql') + }) + + it('leaves non-SQL content types unchanged', () => { + const report = { id: '1', type: 'report' as const, content: { foo: 'bar' } } + + expect(unmapSqlContentField(report)).toBe(report) + }) + + it('leaves SQL snippets whose content has no unchecked_sql field unchanged', () => { + const snippet = { + id: '1', + type: 'sql' as const, + content: { content_id: '1', schema_version: '1.0', sql: 'SELECT 1' }, + } + + expect(unmapSqlContentField(snippet)).toBe(snippet) + }) +}) + +describe('remap/unmap round-trip', () => { + it('returns the original content shape after remap then unmap', () => { + const result = unmapSqlContentField(remapSqlContentField(SQL_SNIPPET)) + + expect(result.content).toEqual(SQL_SNIPPET.content) + }) +}) diff --git a/apps/studio/data/content/content-upsert-mutation.test.ts b/apps/studio/data/content/content-upsert-mutation.test.ts new file mode 100644 index 00000000000..6c632d57e2c --- /dev/null +++ b/apps/studio/data/content/content-upsert-mutation.test.ts @@ -0,0 +1,75 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' + +import { upsertContent } from './content-upsert-mutation' +import { addAPIMock } from '@/tests/lib/msw' + +const SNIPPET_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' +const FOLDER_ID = 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a13' + +const payload = { + id: SNIPPET_ID, + type: 'sql' as const, + name: 'Moved query', + visibility: 'user' as const, + project_id: 1, + owner_id: 1, + folder_id: FOLDER_ID, + content: { + content_id: SNIPPET_ID, + schema_version: '1.0', + unchecked_sql: untrustedSql('SELECT 1'), + }, +} + +describe('upsertContent', () => { + it('remaps content.sql to unchecked_sql in the upsert response', async () => { + // Self-hosted (and the management API) persist + return the SQL body under `content.sql`; + // the editor reads `content.unchecked_sql`. Move/rename consume this response directly, so it + // must be remapped — otherwise the editor renders blank until a refetch. + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + const body = (await request.json()) as { id: string; name: string; type: string } + return HttpResponse.json({ + id: SNIPPET_ID, + type: 'sql', + name: body.name, + description: '', + favorite: false, + folder_id: FOLDER_ID, + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + visibility: 'user', + owner_id: 1, + project_id: 1, + content: { + sql: 'SELECT 1', + content_id: SNIPPET_ID, + schema_version: '1.0', + }, + }) + }, + }) + + const result = await upsertContent({ projectRef: 'default', payload }) + + expect(result?.status).toBe('saved') + expect(result?.content?.unchecked_sql).toEqual(untrustedSql('SELECT 1')) + expect(result?.content).not.toHaveProperty('sql') + }) + + it('returns null when the API responds with no snippet', async () => { + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: () => HttpResponse.json(null), + }) + + const result = await upsertContent({ projectRef: 'default', payload }) + + expect(result).toBeNull() + }) +}) diff --git a/apps/studio/data/content/content-upsert-mutation.ts b/apps/studio/data/content/content-upsert-mutation.ts index 967dcb8629a..26640dbb066 100644 --- a/apps/studio/data/content/content-upsert-mutation.ts +++ b/apps/studio/data/content/content-upsert-mutation.ts @@ -2,9 +2,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import type { Content } from './content-query' -import { unmapSqlContentField } from './content-remap' +import { remapSqlContentField, unmapSqlContentField } from './content-remap' import { contentKeys } from './keys' -import type { Snippet } from './sql-folders-query' +import type { Snippet, SnippetWithContent } from './sql-folders-query' import type { components } from '@/data/api' import { handleError, put } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -23,7 +23,7 @@ export type UpsertContentVariables = { export async function upsertContent( { projectRef, payload }: UpsertContentVariables, signal?: AbortSignal -) { +): Promise<SnippetWithContent | null> { const { data, error } = await put('/platform/projects/{ref}/content', { params: { path: { ref: projectRef } }, body: unmapSqlContentField(payload), @@ -32,7 +32,8 @@ export async function upsertContent( }) if (error) handleError(error) - return data as Snippet | null + const snippet = data as Snippet | null + return snippet === null ? null : { ...remapSqlContentField(snippet), status: 'saved' } } export type UpsertContentData = Awaited<ReturnType<typeof upsertContent>> diff --git a/apps/studio/data/content/snippet-status.ts b/apps/studio/data/content/snippet-status.ts new file mode 100644 index 00000000000..bd89e2fd4c9 --- /dev/null +++ b/apps/studio/data/content/snippet-status.ts @@ -0,0 +1,19 @@ +/** + * The lifecycle of a snippet in the SQL editor, modelled as a single set of + * mutually-exclusive states. + * + * The status is attached to a snippet as it enters the app: 'saved' when + * fetched from the database (see the snippet queries in this directory) and + * 'new' when created locally (see createSqlSnippetSkeletonV2). The transition + * and predicate helpers live in state/sql-editor/sql-editor-lifecycle. + */ +export type SnippetStatus = + // Never persisted to the database (created locally this session): + | 'new' // idle, never saved + | 'new_saving' // first save in flight + | 'new_save_failed' // first save failed + // Persisted to the database at least once: + | 'saved' // no pending changes + | 'unsaved' // has pending local edits + | 'saving' // re-save in flight + | 'save_failed' // last save failed diff --git a/apps/studio/data/content/sql-folder-contents-query.ts b/apps/studio/data/content/sql-folder-contents-query.ts index 94a275e4eb4..e14671bae97 100644 --- a/apps/studio/data/content/sql-folder-contents-query.ts +++ b/apps/studio/data/content/sql-folder-contents-query.ts @@ -1,7 +1,7 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' import { contentKeys } from './keys' -import { SNIPPET_PAGE_LIMIT } from './sql-folders-query' +import { SNIPPET_PAGE_LIMIT, withSavedStatus } from './sql-folders-query' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types' @@ -39,6 +39,7 @@ export async function getSQLSnippetFolderContents( if (error) handleError(error) return { ...data.data, + contents: (data.data.contents ?? []).map(withSavedStatus), cursor: data.cursor, } } diff --git a/apps/studio/data/content/sql-folders-query.ts b/apps/studio/data/content/sql-folders-query.ts index 9aa534fc84a..36d31c6a505 100644 --- a/apps/studio/data/content/sql-folders-query.ts +++ b/apps/studio/data/content/sql-folders-query.ts @@ -2,8 +2,9 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' import { components } from 'api-types' import { contentKeys } from './keys' +import type { SnippetStatus } from './snippet-status' import { get, handleError } from '@/data/fetchers' -import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types' +import type { ResponseError, SqlSnippets, UseCustomInfiniteQueryOptions } from '@/types' export type SnippetFolderResponse = components['schemas']['GetUserContentFolderResponse']['data'] export type SnippetFolder = @@ -11,6 +12,19 @@ export type SnippetFolder = export type Snippet = components['schemas']['GetUserContentFolderResponse']['data']['contents'][number] +export interface SnippetWithContent extends Snippet { + content?: SqlSnippets.Content + status: SnippetStatus +} + +// Attaches the 'saved' lifecycle status to a snippet as it crosses from the +// database into the app. Generic so it preserves any loaded content on the +// snippet, and types `status` as the full SnippetStatus (not the 'saved' +// literal) so the result is a regular SnippetWithContent. +export function withSavedStatus<T extends Snippet>(snippet: T): T & { status: SnippetStatus } { + return { ...snippet, status: 'saved' } +} + export type SQLSnippetFolderVariables = { projectRef?: string cursor?: string @@ -48,6 +62,7 @@ export async function getSQLSnippetFolders( if (error) handleError(error) return { ...data.data, + contents: (data.data.contents ?? []).map(withSavedStatus), cursor: data.cursor, } } diff --git a/apps/studio/data/content/sql-snippets-query.ts b/apps/studio/data/content/sql-snippets-query.ts index ce63bb28e50..afd112f9535 100644 --- a/apps/studio/data/content/sql-snippets-query.ts +++ b/apps/studio/data/content/sql-snippets-query.ts @@ -3,9 +3,9 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' import { Content } from './content-query' import { remapSqlContentFields } from './content-remap' import { contentKeys } from './keys' -import { SNIPPET_PAGE_LIMIT } from './sql-folders-query' +import { SNIPPET_PAGE_LIMIT, withSavedStatus, type Snippet } from './sql-folders-query' import { get } from '@/data/fetchers' -import { UseCustomInfiniteQueryOptions } from '@/types' +import type { SqlSnippets, UseCustomInfiniteQueryOptions } from '@/types' export type SqlSnippet = Extract<Content, { type: 'sql' }> @@ -49,9 +49,12 @@ export async function getSqlSnippets( throw error } + const snippets = remapSqlContentFields( + data.data as unknown as Array<Snippet & { content?: SqlSnippets.Content }> + ) return { cursor: data.cursor, - contents: remapSqlContentFields(data.data as unknown as SqlSnippet[]), + contents: snippets.map(withSavedStatus), } } diff --git a/apps/studio/data/database-extensions/database-extensions-query.ts b/apps/studio/data/database-extensions/database-extensions-query.ts index 5e293a0dcfe..4de92df2958 100644 --- a/apps/studio/data/database-extensions/database-extensions-query.ts +++ b/apps/studio/data/database-extensions/database-extensions-query.ts @@ -6,6 +6,7 @@ import { databaseExtensionsKeys } from './keys' import { executeSql } from '@/data/sql/execute-sql-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { PROJECT_STATUS } from '@/lib/constants' +import { EMPTY_ARR } from '@/lib/void' import type { ResponseError, UseCustomQueryOptions } from '@/types' export type DatabaseExtension = components['schemas']['PostgresExtension'] & { @@ -26,7 +27,7 @@ export async function getDatabaseExtensions( { projectRef, connectionString, sql, queryKey: ['database-extensions'] }, signal ) - return result as DatabaseExtension[] + return (Array.isArray(result) ? result : EMPTY_ARR) as DatabaseExtension[] } export type DatabaseExtensionsData = Awaited<ReturnType<typeof getDatabaseExtensions>> diff --git a/apps/studio/data/database-policies/database-policies-query.ts b/apps/studio/data/database-policies/database-policies-query.ts index 38c687419d1..1a9bf0cea0a 100644 --- a/apps/studio/data/database-policies/database-policies-query.ts +++ b/apps/studio/data/database-policies/database-policies-query.ts @@ -2,7 +2,7 @@ import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constan import { useQuery } from '@tanstack/react-query' import { databasePoliciesKeys } from './keys' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { get, handleError } from '@/data/fetchers' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { PROJECT_STATUS } from '@/lib/constants' diff --git a/apps/studio/data/database/database-query-constants.ts b/apps/studio/data/database/database-query-constants.ts deleted file mode 100644 index f05a837a205..00000000000 --- a/apps/studio/data/database/database-query-constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -// https://www.postgresql.org/docs/current/catalog-pg-constraint.html -export enum FOREIGN_KEY_CASCADE_ACTION { - NO_ACTION = 'a', - RESTRICT = 'r', - CASCADE = 'c', - SET_NULL = 'n', - SET_DEFAULT = 'd', -} diff --git a/apps/studio/data/database/entity-definitions-query.ts b/apps/studio/data/database/entity-definitions-query.ts deleted file mode 100644 index a016eaca4ba..00000000000 --- a/apps/studio/data/database/entity-definitions-query.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { getEntityDefinitionsSql } from '@supabase/pg-meta' -import { useQuery } from '@tanstack/react-query' - -import { databaseKeys } from './keys' -import { executeSql } from '@/data/sql/execute-sql-mutation' -import { ResponseError, UseCustomQueryOptions } from '@/types' - -export type EntityDefinitionsVariables = { - limit?: number - projectRef?: string - connectionString?: string | null - schemas: string[] -} - -export async function getEntityDefinitions( - { projectRef, connectionString, schemas, limit }: EntityDefinitionsVariables, - signal?: AbortSignal -) { - const sql = getEntityDefinitionsSql({ schemas, limit }) - const { result } = await executeSql( - { - projectRef, - connectionString, - sql, - queryKey: ['entity-definitions', schemas], - }, - signal - ) - - return result[0].data.definitions -} - -type EntityDefinition = { id: number; sql: string } -export type EntityDefinitionsData = EntityDefinition[] -export type EntityDefinitionsError = ResponseError - -export const useEntityDefinitionsQuery = <TData = EntityDefinitionsData>( - { projectRef, connectionString, schemas, limit }: EntityDefinitionsVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions<EntityDefinitionsData, EntityDefinitionsError, TData> = {} -) => - useQuery<EntityDefinitionsData, EntityDefinitionsError, TData>({ - queryKey: databaseKeys.entityDefinitions(projectRef, schemas), - queryFn: ({ signal }) => - getEntityDefinitions({ projectRef, connectionString, schemas, limit }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && schemas.length > 0, - ...options, - }) diff --git a/apps/studio/data/database/pgbouncer-status-query.ts b/apps/studio/data/database/pgbouncer-status-query.ts deleted file mode 100644 index 6354168e155..00000000000 --- a/apps/studio/data/database/pgbouncer-status-query.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import { databaseKeys } from './keys' -import { get, handleError } from '@/data/fetchers' -import type { ResponseError, UseCustomQueryOptions } from '@/types' - -export type PgbouncerStatusVariables = { - projectRef?: string -} - -export async function getPgbouncerStatus( - { projectRef }: PgbouncerStatusVariables, - signal?: AbortSignal -) { - if (!projectRef) throw new Error('projectRef is required') - - const { data, error } = await get('/platform/projects/{ref}/config/pgbouncer/status', { - params: { path: { ref: projectRef } }, - signal, - }) - if (error) handleError(error) - return data -} - -export type PgbouncerStatusData = Awaited<ReturnType<typeof getPgbouncerStatus>> -export type PgbouncerStatusError = ResponseError - -export const usePgbouncerStatusQuery = <TData = PgbouncerStatusData>( - { projectRef }: PgbouncerStatusVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions<PgbouncerStatusData, PgbouncerStatusError, TData> = {} -) => - useQuery<PgbouncerStatusData, PgbouncerStatusError, TData>({ - queryKey: databaseKeys.pgbouncerStatus(projectRef), - queryFn: ({ signal }) => getPgbouncerStatus({ projectRef }, signal), - enabled: enabled && typeof projectRef !== 'undefined', - ...options, - }) diff --git a/apps/studio/data/database/schemas-query.ts b/apps/studio/data/database/schemas-query.ts index 9cdced293f7..fe2a816fdcf 100644 --- a/apps/studio/data/database/schemas-query.ts +++ b/apps/studio/data/database/schemas-query.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { databaseKeys } from './keys' import { executeSql } from '@/data/sql/execute-sql-mutation' +import { EMPTY_ARR } from '@/lib/void' import { ResponseError, UseCustomQueryOptions } from '@/types' export type SchemasVariables = { @@ -32,7 +33,7 @@ export async function getSchemas( signal ) - return result + return Array.isArray(result) ? result : EMPTY_ARR } export const useSchemasQuery = <TData = SchemasData>( diff --git a/apps/studio/data/entitlements/entitlements-query.ts b/apps/studio/data/entitlements/entitlements-query.ts index 832ad627abe..697fdcdbb46 100644 --- a/apps/studio/data/entitlements/entitlements-query.ts +++ b/apps/studio/data/entitlements/entitlements-query.ts @@ -3,6 +3,7 @@ import type { components } from 'api-types' import { get, handleError } from '@/data/fetchers' import { organizationKeys } from '@/data/organizations/keys' +import { EMPTY_ARR } from '@/lib/void' import { UseCustomQueryOptions } from '@/types' import { ResponseError } from '@/types/base' @@ -32,7 +33,10 @@ export async function getEntitlements( }) if (error) handleError(error) - return data + return { + ...data, + entitlements: Array.isArray(data?.entitlements) ? data.entitlements : EMPTY_ARR, + } } export type EntitlementsData = Awaited<ReturnType<typeof getEntitlements>> diff --git a/apps/studio/data/fdw/fdws-query.ts b/apps/studio/data/fdw/fdws-query.ts index 66005773fb8..b081cbfd78b 100644 --- a/apps/studio/data/fdw/fdws-query.ts +++ b/apps/studio/data/fdw/fdws-query.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { fdwKeys } from './keys' import { executeSql } from '@/data/sql/execute-sql-mutation' +import { EMPTY_ARR } from '@/lib/void' import { ResponseError, UseCustomQueryOptions } from '@/types' export type FDWColumn = { @@ -43,7 +44,7 @@ export async function getFDWs( signal ) - return result as FDW[] + return (Array.isArray(result) ? result : EMPTY_ARR) as FDW[] } export type FDWsData = Awaited<ReturnType<typeof getFDWs>> diff --git a/apps/studio/data/lint/lint-query.ts b/apps/studio/data/lint/lint-query.ts index 06280a23079..44c7db0c0f6 100644 --- a/apps/studio/data/lint/lint-query.ts +++ b/apps/studio/data/lint/lint-query.ts @@ -5,6 +5,7 @@ import { lintKeys } from './keys' import { get, handleError } from '@/data/fetchers' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { PROJECT_STATUS } from '@/lib/constants' +import { EMPTY_ARR } from '@/lib/void' import type { ResponseError, UseCustomQueryOptions } from '@/types' type ProjectLintsVariables = { @@ -24,7 +25,7 @@ export async function getProjectLints({ projectRef }: ProjectLintsVariables, sig if (error) handleError(error) - return data + return Array.isArray(data) ? data : EMPTY_ARR } export type ProjectLintsData = Awaited<ReturnType<typeof getProjectLints>> diff --git a/apps/studio/data/logs/otel-inspection.utils.ts b/apps/studio/data/logs/otel-inspection.utils.ts index 125d9ebb512..8963d25997b 100644 --- a/apps/studio/data/logs/otel-inspection.utils.ts +++ b/apps/studio/data/logs/otel-inspection.utils.ts @@ -12,6 +12,17 @@ export type OtelLogRow = { log_attributes?: Record<string, any> } +// OTEL timestamps arrive as an ISO string (sometimes space-separated, no zone) +// or as numeric microseconds. Strings have a `T` or `-`; anything else is micros. +export function parseOtelTimestamp(timestamp: unknown): Date { + const ts = String(timestamp ?? '') + const looksLikeIso = /[T-]/.test(ts) + if (!looksLikeIso) return new Date(Number(ts) / 1000) + const withT = ts.includes(' ') ? ts.replace(' ', 'T') : ts + const hasTz = /Z$|[+-]\d{2}:?\d{2}$/.test(withT) + return new Date(hasTz ? withT : `${withT}Z`) +} + const httpStatusToLevel = (status: number): Level => { if (status >= 500) return 'error' if (status >= 400) return 'warning' diff --git a/apps/studio/data/logs/otel-log-keys-query.ts b/apps/studio/data/logs/otel-log-keys-query.ts new file mode 100644 index 00000000000..c4918af0af8 --- /dev/null +++ b/apps/studio/data/logs/otel-log-keys-query.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query' + +import { executeAnalyticsSql } from './execute-analytics-sql' +import { logsAllEndpointUrl } from './logs-endpoint' +import { analyticsLiteral, safeSql } from './safe-analytics-sql' + +const LOOKBACK_HOURS = 24 * 7 + +export async function fetchOtelLogKeys({ + projectRef, + source, + signal, +}: { + projectRef: string + source: string + signal?: AbortSignal +}): Promise<string[]> { + const end = new Date() + const start = new Date(end.getTime() - LOOKBACK_HOURS * 60 * 60 * 1000) + const sql = safeSql`SELECT arrayJoin(mapKeys(log_attributes)) AS key, count() AS n FROM logs WHERE source = ${analyticsLiteral(source)} GROUP BY key ORDER BY n DESC LIMIT 500` + const data = await executeAnalyticsSql({ + projectRef, + endpoint: logsAllEndpointUrl(true), + sql, + iso_timestamp_start: start.toISOString(), + iso_timestamp_end: end.toISOString(), + method: 'post', + signal, + }) + const rows = (data?.result ?? []) as { key: string }[] + return rows.map((r) => r.key).filter(Boolean) +} + +export function useOtelLogKeysQuery( + { projectRef, source }: { projectRef?: string; source?: string }, + { enabled = true }: { enabled?: boolean } = {} +) { + return useQuery({ + queryKey: ['projects', projectRef, 'otel-log-keys', source], + queryFn: ({ signal }) => + fetchOtelLogKeys({ projectRef: projectRef ?? '', source: source ?? '', signal }), + enabled: enabled && Boolean(projectRef) && Boolean(source), + staleTime: 5 * 60 * 1000, + }) +} diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts index edf5d868175..61af3c1b476 100644 --- a/apps/studio/data/logs/unified-logs-infinite-query.ts +++ b/apps/studio/data/logs/unified-logs-infinite-query.ts @@ -4,6 +4,7 @@ import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint' +import { parseOtelTimestamp } from './otel-inspection.utils' import { analyticsLiteral, safeSql } from './safe-analytics-sql' import { extractLogMetadata } from './unified-logs.utils' import { getUnifiedLogsQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' @@ -124,16 +125,7 @@ export async function getUnifiedLogs( const resultData = data?.result ?? [] const result = resultData.map((row: any) => { - // Disambiguate timestamp shape by format, not by Number.isFinite — a - // numeric string of microseconds is always finite, so checking finite- - // ness can't tell us whether the value is microseconds or already-ms. - // ISO-like strings contain `T` or `-` and are parsed via Date; anything - // else is treated as numeric microseconds-since-epoch. - const ts = String(row.timestamp ?? '') - const looksLikeIso = /[T-]/.test(ts) - const date = looksLikeIso - ? new Date(/Z$|[+-]\d{2}:?\d{2}$/.test(ts) ? ts : `${ts}Z`) - : new Date(Number(ts) / 1000) + const date = parseOtelTimestamp(row.timestamp) const { status, method, pathname } = extractLogMetadata(row) diff --git a/apps/studio/data/misc/parse-query-mutation.ts b/apps/studio/data/misc/parse-query-mutation.ts index 0a36df7bca5..a30e4eb930d 100644 --- a/apps/studio/data/misc/parse-query-mutation.ts +++ b/apps/studio/data/misc/parse-query-mutation.ts @@ -6,9 +6,12 @@ import { BASE_PATH } from '@/lib/constants' import { ResponseError, UseCustomMutationOptions } from '@/types' type ParseSQLQueryVariables = { sql: string } +export type ParseSQLQueryOperations = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | undefined export type ParseSQLQueryResponse = { tables: string[] - operation: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | undefined + operation: ParseSQLQueryOperations + whereClause: string | null + statementCount: number } export async function parseSQLQuery({ sql }: ParseSQLQueryVariables) { diff --git a/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts b/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts index c15e19deea5..ccd70b2cd29 100644 --- a/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts +++ b/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { handleError, post } from '@/data/fetchers' import { invalidateOrganizationsQuery } from '@/data/organizations/organizations-query' +import { invalidatePermissionsQuery } from '@/data/permissions/permissions-query' import { useInvalidateProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -47,8 +48,11 @@ export const useOrganizationAcceptInvitationMutation = ({ >({ mutationFn: (vars) => acceptOrganizationInvitation(vars), async onSuccess(data, variables, context) { - await invalidateOrganizationsQuery(queryClient) - await invalidateProjectsQuery() + await Promise.all([ + invalidateOrganizationsQuery(queryClient), + invalidateProjectsQuery(), + invalidatePermissionsQuery(queryClient), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/permissions/permissions-query.ts b/apps/studio/data/permissions/permissions-query.ts index 8102973331f..29805386523 100644 --- a/apps/studio/data/permissions/permissions-query.ts +++ b/apps/studio/data/permissions/permissions-query.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, type QueryClient } from '@tanstack/react-query' import { useIsLoggedIn } from 'common' import { permissionKeys } from './keys' @@ -44,3 +44,7 @@ export const usePermissionsQuery = <TData = PermissionsData>({ staleTime: 5 * 60 * 1000, }) } + +export function invalidatePermissionsQuery(client: QueryClient) { + return client.invalidateQueries({ queryKey: permissionKeys.list() }) +} diff --git a/apps/studio/data/projects/projects-infinite-query.ts b/apps/studio/data/projects/projects-infinite-query.ts index fe114fa911e..0e3b4dfcaf5 100644 --- a/apps/studio/data/projects/projects-infinite-query.ts +++ b/apps/studio/data/projects/projects-infinite-query.ts @@ -70,9 +70,9 @@ export const useProjectsInfiniteQuery = <TData = ProjectsInfiniteData>( const page = pages.length const currentTotalCount = page * limit // @ts-ignore [Joshen] API type issue for Version 2 endpoints - const totalCount = lastPage.pagination.count + const totalCount = lastPage?.pagination?.count - if (currentTotalCount >= totalCount) return undefined + if (totalCount === undefined || currentTotalCount >= totalCount) return undefined return page }, ...options, diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts index ed6c4c089c1..66004344a18 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts @@ -78,4 +78,37 @@ describe('buildDucklakeApiConfig', () => { }, }) }) + + it('omits blank custom secret fields when requested', () => { + expect( + buildDucklakeApiConfig( + { + catalogUrl: ' ', + dataPath: 's3://bucket/path', + poolSize: 4, + s3AccessKeyId: '', + s3SecretAccessKey: '\n', + s3Region: 'eu-west-1', + s3Endpoint: 's3.example.com', + s3UrlStyle: 'path', + s3UseSsl: true, + metadataSchema: 'ducklake', + }, + { omitBlankSecrets: true } + ) + ).toEqual({ + ducklake: { + catalog_url: undefined, + data_path: 's3://bucket/path', + pool_size: 4, + s3_access_key_id: undefined, + s3_secret_access_key: undefined, + s3_region: 'eu-west-1', + s3_endpoint: 's3.example.com', + s3_url_style: 'path', + s3_use_ssl: true, + metadata_schema: 'ducklake', + }, + }) + }) }) diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.ts index a2682ea00d3..4be39468cd0 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import type { components } from 'api-types' import { toast } from 'sonner' +import { optionalSecret } from './destination-secret-utils' import { replicationKeys } from './keys' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -66,9 +67,20 @@ function isDucklakeSupabaseConfig( return 'catalogProjectRef' in config } +const maybeOmitBlankSecret = (value: string | undefined, omitBlankSecrets: boolean) => { + if (omitBlankSecrets) return optionalSecret(value) + + return value +} + // Maps the studio-side DuckLake config to the snake_case `{ ducklake: ... }` payload accepted // by the platform API. Shared by the create / update / validate mutations. -export function buildDucklakeApiConfig(config: DucklakeDestinationConfig) { +export function buildDucklakeApiConfig( + config: DucklakeDestinationConfig, + options: { omitBlankSecrets?: boolean } = {} +) { + const omitBlankSecrets = options.omitBlankSecrets ?? false + if (isDucklakeSupabaseConfig(config)) { return { ducklake: { @@ -92,11 +104,11 @@ export function buildDucklakeApiConfig(config: DucklakeDestinationConfig) { return { ducklake: { - catalog_url: config.catalogUrl, + catalog_url: maybeOmitBlankSecret(config.catalogUrl, omitBlankSecrets), data_path: config.dataPath, pool_size: config.poolSize, - s3_access_key_id: config.s3AccessKeyId, - s3_secret_access_key: config.s3SecretAccessKey, + s3_access_key_id: maybeOmitBlankSecret(config.s3AccessKeyId, omitBlankSecrets), + s3_secret_access_key: maybeOmitBlankSecret(config.s3SecretAccessKey, omitBlankSecrets), s3_region: config.s3Region, s3_endpoint: config.s3Endpoint, s3_url_style: config.s3UrlStyle, diff --git a/apps/studio/data/replication/delete-tenant-mutation.ts b/apps/studio/data/replication/delete-tenant-mutation.ts index d7696c6225c..26033fae249 100644 --- a/apps/studio/data/replication/delete-tenant-mutation.ts +++ b/apps/studio/data/replication/delete-tenant-mutation.ts @@ -55,7 +55,7 @@ export const useDeleteReplicationTenantMutation = ({ }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to disable external replication: ${data.message}`) + toast.error(`Failed to disable Pipelines: ${data.message}`) } else { onError(data, variables, context) } diff --git a/apps/studio/data/replication/destination-secret-utils.test.ts b/apps/studio/data/replication/destination-secret-utils.test.ts new file mode 100644 index 00000000000..e9838d5e8e1 --- /dev/null +++ b/apps/studio/data/replication/destination-secret-utils.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { optionalSecret } from './destination-secret-utils' + +describe('optionalSecret', () => { + it('omits blank secret values', () => { + expect(optionalSecret('')).toBeUndefined() + expect(optionalSecret(' ')).toBeUndefined() + expect(optionalSecret(undefined)).toBeUndefined() + }) + + it('omits masked placeholder values even when shortened', () => { + expect(optionalSecret('••••••••••••••••')).toBeUndefined() + expect(optionalSecret('••••••••')).toBeUndefined() + expect(optionalSecret(' •••••• ')).toBeUndefined() + }) + + it('keeps provided replacement secrets', () => { + expect(optionalSecret('new-secret')).toBe('new-secret') + }) +}) diff --git a/apps/studio/data/replication/destination-secret-utils.ts b/apps/studio/data/replication/destination-secret-utils.ts new file mode 100644 index 00000000000..bda1f5ea666 --- /dev/null +++ b/apps/studio/data/replication/destination-secret-utils.ts @@ -0,0 +1,11 @@ +const MASKED_SECRET_PLACEHOLDER_PATTERN = /^•+$/ + +export const optionalSecret = (value: string | undefined) => { + const trimmed = value?.trim() + + if (!trimmed || MASKED_SECRET_PLACEHOLDER_PATTERN.test(trimmed)) { + return undefined + } + + return value +} diff --git a/apps/studio/data/replication/update-destination-pipeline-mutation.ts b/apps/studio/data/replication/update-destination-pipeline-mutation.ts index 53695c9e458..ea537ae95ea 100644 --- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts @@ -7,6 +7,7 @@ import { buildDucklakeApiConfig, DestinationConfig, } from './create-destination-pipeline-mutation' +import { optionalSecret } from './destination-secret-utils' import { replicationKeys } from './keys' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -27,6 +28,11 @@ export type UpdateDestinationPipelineParams = { } } +type UpdateDestinationPipelineBody = + components['schemas']['UpdateReplicationDestinationPipelineBody'] +type UpdateDestinationConfig = UpdateDestinationPipelineBody['destination_config'] +type UpdatePipelineConfig = UpdateDestinationPipelineBody['pipeline_config'] + async function updateDestinationPipeline( { destinationId: destinationId, @@ -48,7 +54,7 @@ async function updateDestinationPipeline( if (!projectRef) throw new Error('projectRef is required') // Build destination_config based on the type - let destination_config: components['schemas']['UpdateReplicationDestinationPipelineBody']['destination_config'] + let destination_config: UpdateDestinationConfig if ('bigQuery' in destinationConfig) { const { projectId, datasetId, serviceAccountKey, connectionPoolSize, maxStalenessMins } = @@ -57,11 +63,11 @@ async function updateDestinationPipeline( big_query: { project_id: projectId, dataset_id: datasetId, - service_account_key: serviceAccountKey, + service_account_key: optionalSecret(serviceAccountKey), connection_pool_size: connectionPoolSize, max_staleness_mins: maxStalenessMins, }, - } as components['schemas']['UpdateReplicationDestinationPipelineBody']['destination_config'] + } as UpdateDestinationConfig } else if ('iceberg' in destinationConfig) { const { projectRef: icebergProjectRef, @@ -78,17 +84,17 @@ async function updateDestinationPipeline( project_ref: icebergProjectRef, warehouse_name: warehouseName, namespace: namespace, - catalog_token: catalogToken, - s3_access_key_id: s3AccessKeyId, - s3_secret_access_key: s3SecretAccessKey, + catalog_token: optionalSecret(catalogToken), + s3_access_key_id: optionalSecret(s3AccessKeyId), + s3_secret_access_key: optionalSecret(s3SecretAccessKey), s3_region: s3Region, }, }, - } + } as UpdateDestinationConfig } else if ('ducklake' in destinationConfig) { - destination_config = buildDucklakeApiConfig( - destinationConfig.ducklake - ) as components['schemas']['UpdateReplicationDestinationPipelineBody']['destination_config'] + destination_config = buildDucklakeApiConfig(destinationConfig.ducklake, { + omitBlankSecrets: true, + }) as UpdateDestinationConfig } else if ('snowflake' in destinationConfig) { const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = destinationConfig.snowflake @@ -96,13 +102,13 @@ async function updateDestinationPipeline( snowflake: { account_id: accountId, user, - private_key: privateKey, - private_key_passphrase: privateKeyPassphrase, + private_key: optionalSecret(privateKey), + private_key_passphrase: optionalSecret(privateKeyPassphrase), database, schema, role, }, - } as unknown as components['schemas']['UpdateReplicationDestinationPipelineBody']['destination_config'] + } as unknown as UpdateDestinationConfig } else { throw new Error( 'Invalid destination config: must specify bigQuery, iceberg, ducklake, or snowflake' @@ -125,8 +131,7 @@ async function updateDestinationPipeline( destination_config, source_id: sourceId, destination_name: destinationName, - pipeline_config: - pipeline_config as components['schemas']['UpdateReplicationDestinationPipelineBody']['pipeline_config'], + pipeline_config: pipeline_config as UpdatePipelineConfig, }, signal, } diff --git a/apps/studio/data/storage/bucket-delete-mutation.ts b/apps/studio/data/storage/bucket-delete-mutation.ts index e789f7de73e..67870781f21 100644 --- a/apps/studio/data/storage/bucket-delete-mutation.ts +++ b/apps/studio/data/storage/bucket-delete-mutation.ts @@ -45,9 +45,17 @@ export const useBucketDeleteMutation = ({ return useMutation<BucketDeleteData, ResponseError, BucketDeleteVariables>({ mutationFn: (vars) => deleteBucket(vars), async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries({ queryKey: storageKeys.buckets(projectRef) }) + const { projectRef, id } = variables + + const deletedBucketQueryKey = storageKeys.bucket(projectRef, id) + await queryClient.cancelQueries({ queryKey: deletedBucketQueryKey }) + queryClient.removeQueries({ queryKey: deletedBucketQueryKey }) + await onSuccess?.(data, variables, context) + + // Fire-and-forget: only the bucket list needs refreshing, and it shouldn't block + // onSuccess (modal close/navigation) above. + void queryClient.invalidateQueries({ queryKey: storageKeys.bucketsList(projectRef) }) }, async onError(data, variables, context) { if (onError === undefined) { diff --git a/apps/studio/data/storage/buckets-query.ts b/apps/studio/data/storage/buckets-query.ts index f566d1b7445..ab9896e98e0 100644 --- a/apps/studio/data/storage/buckets-query.ts +++ b/apps/studio/data/storage/buckets-query.ts @@ -227,6 +227,11 @@ export const useBucketInfoQueryPreferCached = (bucketId?: string, projectRef?: s const shouldRetryBucketsQuery = (failureCount: number, error: unknown) => { if (error instanceof ResponseError) { + // If the bucket doesn't exist or was deleted, don't retry — it will never succeed. + if (error.code === 404) { + return false + } + if ( error.message.includes('Missing tenant config') || error.message.includes('Project has no active API keys') diff --git a/apps/studio/data/subscriptions/project-addons-query.ts b/apps/studio/data/subscriptions/project-addons-query.ts index 5f98d4c310e..28a7b7cd25c 100644 --- a/apps/studio/data/subscriptions/project-addons-query.ts +++ b/apps/studio/data/subscriptions/project-addons-query.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { subscriptionKeys } from './keys' import { get, handleError } from '@/data/fetchers' import { IS_PLATFORM } from '@/lib/constants' +import { EMPTY_ARR } from '@/lib/void' import type { ResponseError, UseCustomQueryOptions } from '@/types' export type ProjectAddonsVariables = { @@ -24,7 +25,11 @@ export async function getProjectAddons( }) if (error) handleError(error) - return data + return { + ...data, + selected_addons: Array.isArray(data?.selected_addons) ? data.selected_addons : EMPTY_ARR, + available_addons: Array.isArray(data?.available_addons) ? data.available_addons : EMPTY_ARR, + } } export type ProjectAddonsData = Awaited<ReturnType<typeof getProjectAddons>> diff --git a/apps/studio/data/table-rows/operation-queue-save-mutation.test.ts b/apps/studio/data/table-rows/operation-queue-save-mutation.test.ts new file mode 100644 index 00000000000..e81a01e92fb --- /dev/null +++ b/apps/studio/data/table-rows/operation-queue-save-mutation.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { getOperationSqlStatements } from './operation-queue-save-mutation' +import type { Entity } from '@/data/table-editor/table-editor-types' +import { QueuedOperation, QueuedOperationType } from '@/state/table-editor-operation-queue.types' + +const usersTable = { id: 1, name: 'users', schema: 'public' } as Entity + +function createEditOperation( + columnName: string, + newValue: unknown, + rowIdentifiers: Record<string, unknown> = { id: 1 } +): QueuedOperation { + return { + id: `${columnName}-${String(newValue)}`, + tableId: 1, + timestamp: 1, + type: QueuedOperationType.EDIT_CELL_CONTENT, + payload: { + rowIdentifiers, + columnName, + oldValue: undefined, + newValue, + table: usersTable, + enumArrayColumns: [], + }, + } +} + +describe('getOperationSqlStatements', () => { + it('merges edits for the same row into one update statement', () => { + const statements = getOperationSqlStatements([ + createEditOperation('id', '4'), + createEditOperation('name', 'Ram 1'), + ]) + + expect(statements).toHaveLength(1) + + const sql = String(statements[0]) + expect(sql).toContain('update public.users set (id,name)') + expect(sql).toContain('"id":"4"') + expect(sql).toContain('"name":"Ram 1"') + expect(sql.match(/where id = 1/g)).toHaveLength(1) + }) + + it('keeps edits for different rows as separate update statements', () => { + const statements = getOperationSqlStatements([ + createEditOperation('name', 'Ram 1', { id: 1 }), + createEditOperation('name', 'Shyam 1', { id: 2 }), + ]) + + expect(statements).toHaveLength(2) + expect(String(statements[0])).toContain('where id = 1') + expect(String(statements[1])).toContain('where id = 2') + }) + + it('does not merge rows whose identifier values would collide with delimiter keys', () => { + const statements = getOperationSqlStatements([ + createEditOperation('name', 'Row 1', { a: 'x', b: 'y|b:z' }), + createEditOperation('name', 'Row 2', { a: 'x|b:y', b: 'z' }), + ]) + + expect(statements).toHaveLength(2) + }) +}) diff --git a/apps/studio/data/table-rows/operation-queue-save-mutation.ts b/apps/studio/data/table-rows/operation-queue-save-mutation.ts index 67c554cd151..e31e920e8fc 100644 --- a/apps/studio/data/table-rows/operation-queue-save-mutation.ts +++ b/apps/studio/data/table-rows/operation-queue-save-mutation.ts @@ -11,9 +11,22 @@ import type { PendingAddRow } from '@/components/grid/types' import { executeSql } from '@/data/sql/execute-sql-mutation' import { RoleImpersonationState, wrapWithRoleImpersonation } from '@/lib/role-impersonation' import { isRoleImpersonationEnabled } from '@/state/role-impersonation-state' -import { QueuedOperation, QueuedOperationType } from '@/state/table-editor-operation-queue.types' +import { + isEditCellContentOperation, + QueuedOperation, + QueuedOperationType, + type EditCellContentOperation, +} from '@/state/table-editor-operation-queue.types' import type { ResponseError, UseCustomMutationOptions } from '@/types' +function getEditCellOperationRowKey(operation: EditCellContentOperation): string { + const rowIdentifiersKey = JSON.stringify( + Object.entries(operation.payload.rowIdentifiers).sort(([a], [b]) => a.localeCompare(b)) + ) + + return `${operation.tableId}:${rowIdentifiersKey}` +} + export type OperationQueueSaveVariables = { projectRef: string connectionString?: string | null @@ -81,6 +94,55 @@ function sortOperations(operations: readonly QueuedOperation[]): QueuedOperation }) } +function stripTrailingSemicolon(sql: SafeSqlFragment): SafeSqlFragment { + return (sql.endsWith(';') ? sql.slice(0, -1) : sql) as SafeSqlFragment +} + +export function getOperationSqlStatements( + operations: readonly QueuedOperation[] +): Array<SafeSqlFragment> { + const statements: Array<SafeSqlFragment> = [] + const editCellOperationsByRow = new Map<string, EditCellContentOperation[]>() + + for (const operation of sortOperations(operations)) { + if (!isEditCellContentOperation(operation)) { + statements.push(stripTrailingSemicolon(getOperationSql(operation))) + continue + } + + const key = getEditCellOperationRowKey(operation) + const existing = editCellOperationsByRow.get(key) + if (existing) { + existing.push(operation) + continue + } + editCellOperationsByRow.set(key, [operation]) + } + + for (const editCellOperations of editCellOperationsByRow.values()) { + const firstOperation = editCellOperations[0] + const { table, rowIdentifiers } = firstOperation.payload + + statements.push( + stripTrailingSemicolon( + getTableRowUpdateSql({ + table: { id: table.id, name: table.name, schema: table.schema }, + configuration: { identifiers: rowIdentifiers }, + payload: Object.fromEntries( + editCellOperations.map(({ payload }) => [payload.columnName, payload.newValue]) + ), + enumArrayColumns: [ + ...new Set(editCellOperations.flatMap(({ payload }) => payload.enumArrayColumns ?? [])), + ], + returning: false, + }) + ) + ) + } + + return statements +} + /** * Saves all queued operations in a single database transaction. * If any operation fails, the entire transaction is rolled back. @@ -95,11 +157,7 @@ export async function saveOperationQueue({ return { result: [] } } - const sortedOperations = sortOperations(operations) - const statements: Array<SafeSqlFragment> = sortedOperations.map((op) => { - const sql = getOperationSql(op) - return (sql.endsWith(';') ? sql.slice(0, -1) : sql) as SafeSqlFragment - }) + const statements = getOperationSqlStatements(operations) const transactionSql = wrapWithTransaction(safeSql`${joinSqlFragments(statements, ';\n')};`) diff --git a/apps/studio/data/table-rows/utils.test.ts b/apps/studio/data/table-rows/utils.test.ts new file mode 100644 index 00000000000..d6e449618d8 --- /dev/null +++ b/apps/studio/data/table-rows/utils.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { formatFilterValue } from './utils' +import type { Filter } from '@/components/grid/types' + +const table = { + columns: [ + { name: 'id', format: 'int8' }, + { name: 'count', format: 'int4' }, + { name: 'name', format: 'text' }, + { name: 'price', format: 'numeric' }, + ], +} + +const makeFilter = (column: string, value: any): Filter => ({ + column, + operator: '=', + value, +}) + +describe('formatFilterValue', () => { + it('returns the value untouched for non-numerical columns', () => { + expect(formatFilterValue(table, makeFilter('name', '123'))).toBe('123') + }) + + it('coerces in-range numerical values to a number', () => { + expect(formatFilterValue(table, makeFilter('count', '42'))).toBe(42) + expect(formatFilterValue(table, makeFilter('id', '-42'))).toBe(-42) + }) + + it('returns the original string when the value is not a valid number', () => { + expect(formatFilterValue(table, makeFilter('count', 'abc'))).toBe('abc') + }) + + it('preserves large positive bigints as strings to avoid precision loss', () => { + const int8Max = '9223372036854775807' + expect(formatFilterValue(table, makeFilter('id', int8Max))).toBe(int8Max) + }) + + // Regression: the previous implementation only guarded the upper bound + // (numberValue > MAX_SAFE_INTEGER), so large negative bigints were converted + // through Number() and silently rounded, producing a wrong filter value. + it('preserves large negative bigints as strings to avoid precision loss', () => { + const int8Min = '-9223372036854775808' + expect(formatFilterValue(table, makeFilter('id', int8Min))).toBe(int8Min) + + const justBelowSafe = '-9007199254740993' + expect(formatFilterValue(table, makeFilter('id', justBelowSafe))).toBe(justBelowSafe) + }) + + it('keeps the exact safe-integer bounds as numbers', () => { + expect(formatFilterValue(table, makeFilter('id', String(Number.MAX_SAFE_INTEGER)))).toBe( + Number.MAX_SAFE_INTEGER + ) + expect(formatFilterValue(table, makeFilter('id', String(Number.MIN_SAFE_INTEGER)))).toBe( + Number.MIN_SAFE_INTEGER + ) + }) +}) diff --git a/apps/studio/data/table-rows/utils.ts b/apps/studio/data/table-rows/utils.ts index 4feb6adb43d..80aa54d9ded 100644 --- a/apps/studio/data/table-rows/utils.ts +++ b/apps/studio/data/table-rows/utils.ts @@ -18,9 +18,9 @@ export function formatFilterValue( const column = table.columns.find((x) => x.name == filter.column) if (column && isNumericalColumn(column.format)) { const numberValue = Number(filter.value) - // Supports BigInt filter values - if (Number.isNaN(numberValue) || numberValue > Number.MAX_SAFE_INTEGER) return filter.value - else return Number(filter.value) + if (Number.isNaN(numberValue) || Math.abs(numberValue) > Number.MAX_SAFE_INTEGER) + return filter.value + else return numberValue } return filter.value } diff --git a/apps/studio/data/usage/resource-warnings-query.ts b/apps/studio/data/usage/resource-warnings-query.ts index df7385ea546..73ef9ca1565 100644 --- a/apps/studio/data/usage/resource-warnings-query.ts +++ b/apps/studio/data/usage/resource-warnings-query.ts @@ -4,6 +4,7 @@ import { IS_PLATFORM } from 'common' import { usageKeys } from './keys' import type { components } from '@/data/api' import { get, handleError } from '@/data/fetchers' +import { EMPTY_ARR } from '@/lib/void' import type { ResponseError, UseCustomQueryOptions } from '@/types' export type ResourceWarningsVariables = { @@ -26,7 +27,7 @@ export async function getResourceWarnings( }) if (error) handleError(error) - return data + return Array.isArray(data) ? data : EMPTY_ARR } export type ResourceWarning = components['schemas']['ProjectResourceWarningsResponse'] diff --git a/apps/studio/data/utils/deployment-commit-query.ts b/apps/studio/data/utils/deployment-commit-query.ts index 44df7f26534..8729bd57cfd 100644 --- a/apps/studio/data/utils/deployment-commit-query.ts +++ b/apps/studio/data/utils/deployment-commit-query.ts @@ -5,7 +5,15 @@ import { BASE_PATH } from '@/lib/constants' import type { ResponseError, UseCustomQueryOptions } from '@/types' export async function getDeploymentCommit() { - const response = await fetchHandler(`${BASE_PATH}/api/get-deployment-commit`) + // `credentials: 'omit'` drops cookies for this one request — including the + // `__vdpl` skew-protection pin (TanStack, see router.tsx). With no pin cookie, + // Vercel's edge routes it to the LATEST deployment, so this check can detect a + // newer version even while the rest of the session stays pinned. The endpoint + // is public (no auth needed), and we keep the basePath URL so it still routes + // to studio in production (root `/api/*` there is the marketing site). + const response = await fetchHandler(`${BASE_PATH}/api/get-deployment-commit`, { + credentials: 'omit', + }) return (await response.json()) as { commitSha: string; commitTime: string } } diff --git a/apps/studio/data/utils/pagination.ts b/apps/studio/data/utils/pagination.ts deleted file mode 100644 index ea72c3e45d6..00000000000 --- a/apps/studio/data/utils/pagination.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* Utils for use in queries / mutations */ - -export const DEFAULT_PAGE_SIZE = 100 - -export function getPagination(page?: number, size: number = DEFAULT_PAGE_SIZE) { - const limit = size - const from = page ? page * limit : 0 - const to = page ? from + size - 1 : size - 1 - - return { from, to } -} diff --git a/apps/studio/data/vault/vault-secret-update-mutation.ts b/apps/studio/data/vault/vault-secret-update-mutation.ts index 942f722fdf8..f53237d998e 100644 --- a/apps/studio/data/vault/vault-secret-update-mutation.ts +++ b/apps/studio/data/vault/vault-secret-update-mutation.ts @@ -10,6 +10,7 @@ export type VaultSecretUpdateVariables = { projectRef: string connectionString?: string | null id: string + skipClearCache?: boolean } & Partial<VaultSecret> export async function updateVaultSecret({ @@ -40,9 +41,15 @@ export const useVaultSecretUpdateMutation = ({ return useMutation<VaultSecretUpdateData, ResponseError, VaultSecretUpdateVariables>({ mutationFn: (vars) => updateVaultSecret(vars), async onSuccess(data, variables, context) { - const { id, projectRef } = variables + const { id, projectRef, skipClearCache = false } = variables await Promise.all([ - queryClient.removeQueries({ queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id) }), + !skipClearCache + ? queryClient.removeQueries({ + queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id), + }) + : queryClient.invalidateQueries({ + queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id), + }), queryClient.invalidateQueries({ queryKey: vaultSecretsKeys.list(projectRef) }), ]) await onSuccess?.(data, variables, context) diff --git a/apps/studio/fonts/CustomFont-Black.woff2 b/apps/studio/fonts/CustomFont-Black.woff2 deleted file mode 100644 index e3c834e57f2..00000000000 Binary files a/apps/studio/fonts/CustomFont-Black.woff2 and /dev/null differ diff --git a/apps/studio/fonts/CustomFont-BlackItalic.woff2 b/apps/studio/fonts/CustomFont-BlackItalic.woff2 deleted file mode 100644 index f84036283ec..00000000000 Binary files a/apps/studio/fonts/CustomFont-BlackItalic.woff2 and /dev/null differ diff --git a/apps/studio/fonts/CustomFont-Bold.woff2 b/apps/studio/fonts/CustomFont-Bold.woff2 deleted file mode 100644 index 5e7af459481..00000000000 Binary files a/apps/studio/fonts/CustomFont-Bold.woff2 and /dev/null differ diff --git a/apps/studio/fonts/CustomFont-BoldItalic.woff2 b/apps/studio/fonts/CustomFont-BoldItalic.woff2 deleted file mode 100644 index ac0edd55fd3..00000000000 Binary files a/apps/studio/fonts/CustomFont-BoldItalic.woff2 and /dev/null differ diff --git a/apps/studio/fonts/CustomFont-Book.woff2 b/apps/studio/fonts/CustomFont-Book.woff2 deleted file mode 100644 index abd31f7ec27..00000000000 Binary files a/apps/studio/fonts/CustomFont-Book.woff2 and /dev/null differ diff --git a/apps/studio/fonts/CustomFont-BookItalic.woff2 b/apps/studio/fonts/CustomFont-BookItalic.woff2 deleted file mode 100644 index d326c8672cc..00000000000 Binary files a/apps/studio/fonts/CustomFont-BookItalic.woff2 and /dev/null differ diff --git a/apps/studio/fonts/CustomFont-Medium.woff2 b/apps/studio/fonts/CustomFont-Medium.woff2 deleted file mode 100644 index c07131ddec9..00000000000 Binary files a/apps/studio/fonts/CustomFont-Medium.woff2 and /dev/null differ diff --git a/apps/studio/fonts/index.ts b/apps/studio/fonts/index.ts index 272cd5acd2d..431e1d45203 100644 --- a/apps/studio/fonts/index.ts +++ b/apps/studio/fonts/index.ts @@ -1,53 +1,44 @@ -import { Source_Code_Pro } from 'next/font/google' +import { Manrope } from 'next/font/google' import localFont from 'next/font/local' -export const customFont = localFont({ - variable: '--font-custom', +export const manrope = Manrope({ + variable: '--font-manrope', display: 'swap', - fallback: ['Circular', 'custom-font', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], + subsets: ['latin'], +}) + +export const inter = localFont({ + variable: '--font-inter', + display: 'swap', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], src: [ { - path: './CustomFont-Book.woff2', - weight: '400', + path: './inter/InterVariable.woff2', + weight: '100 900', style: 'normal', }, { - path: './CustomFont-BookItalic.woff2', - weight: '400', - style: 'italic', - }, - { - path: './CustomFont-Medium.woff2', - weight: '500', - style: 'normal', - }, - { - path: './CustomFont-Bold.woff2', - weight: '700', - style: 'normal', - }, - { - path: './CustomFont-BoldItalic.woff2', - weight: '700', - style: 'italic', - }, - { - path: './CustomFont-Black.woff2', - weight: '800', - style: 'normal', - }, - { - path: './CustomFont-BlackItalic.woff2', - weight: '800', + path: './inter/InterVariable-Italic.woff2', + weight: '100 900', style: 'italic', }, ], }) -export const sourceCodePro = Source_Code_Pro({ - subsets: ['latin'], - fallback: ['Source Code Pro', 'Office Code Pro', 'Menlo', 'monospace'], +export const sourceCodePro = localFont({ variable: '--font-source-code-pro', display: 'swap', - weight: ['400', '500', '600', '700'], + fallback: ['Source Code Pro', 'Office Code Pro', 'Menlo', 'monospace'], + src: [ + { + path: './source-code-pro/SourceCodePro-Variable.woff2', + weight: '200 900', + style: 'normal', + }, + { + path: './source-code-pro/SourceCodePro-Variable-Italic.woff2', + weight: '200 900', + style: 'italic', + }, + ], }) diff --git a/apps/studio/fonts/inter/InterVariable-Italic.woff2 b/apps/studio/fonts/inter/InterVariable-Italic.woff2 new file mode 100644 index 00000000000..2f06c65192b Binary files /dev/null and b/apps/studio/fonts/inter/InterVariable-Italic.woff2 differ diff --git a/apps/studio/fonts/inter/InterVariable.woff2 b/apps/studio/fonts/inter/InterVariable.woff2 new file mode 100644 index 00000000000..d15208de03c Binary files /dev/null and b/apps/studio/fonts/inter/InterVariable.woff2 differ diff --git a/apps/studio/fonts/source-code-pro/SourceCodePro-Variable-Italic.woff2 b/apps/studio/fonts/source-code-pro/SourceCodePro-Variable-Italic.woff2 new file mode 100644 index 00000000000..893f998497c Binary files /dev/null and b/apps/studio/fonts/source-code-pro/SourceCodePro-Variable-Italic.woff2 differ diff --git a/apps/studio/fonts/source-code-pro/SourceCodePro-Variable.woff2 b/apps/studio/fonts/source-code-pro/SourceCodePro-Variable.woff2 new file mode 100644 index 00000000000..bc303f50c5d Binary files /dev/null and b/apps/studio/fonts/source-code-pro/SourceCodePro-Variable.woff2 differ diff --git a/apps/studio/hooks/analytics/useLogsPreview.tsx b/apps/studio/hooks/analytics/useLogsPreview.tsx index 6dc632f4f14..4521066f0aa 100644 --- a/apps/studio/hooks/analytics/useLogsPreview.tsx +++ b/apps/studio/hooks/analytics/useLogsPreview.tsx @@ -1,4 +1,5 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useFlag } from 'common' import dayjs from 'dayjs' import { useCallback, useMemo, useState } from 'react' @@ -24,7 +25,14 @@ import { genCountQuery, genDefaultQuery, } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { + genChartQueryOtel, + genCountQueryOtel, + genDefaultQueryOtel, + mapOtelPreviewRow, +} from '@/components/interfaces/Settings/Logs/Logs.utils.otel' import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { logsAllEndpointUrl, pickLogsQueryBuilder } from '@/data/logs/logs-endpoint' interface LogsPreviewHook { logData: LogData[] @@ -55,6 +63,10 @@ function useLogsPreview({ const defaultHelper = getDefaultHelper(PREVIEWER_DATEPICKER_HELPERS) const [latestRefresh, setLatestRefresh] = useState(new Date().toISOString()) + // When on, query the OTEL endpoint instead of BigQuery. logsAllEndpointUrl is + // inlined in each queryFn (not hoisted) so useOtel stays the only query dep. + const useOtel = useFlag('otelLegacyLogs') + const { timestampStart: urlTimestampStart, timestampEnd: urlTimestampEnd, @@ -81,8 +93,13 @@ function useLogsPreview({ ) const defaultSql = useMemo( - () => genDefaultQuery(table, mergedFilters, limit), - [table, mergedFilters, limit] + () => + pickLogsQueryBuilder(useOtel, genDefaultQueryOtel, genDefaultQuery)( + table, + mergedFilters, + limit + ), + [useOtel, table, mergedFilters, limit] ) const params: LogsEndpointParams = useMemo( @@ -95,8 +112,8 @@ function useLogsPreview({ ) const queryKey = useMemo( - () => ['projects', projectRef, 'logs', params, defaultSql], - [projectRef, params, defaultSql] + () => ['projects', projectRef, 'logs', params, defaultSql, { otel: useOtel }], + [projectRef, params, defaultSql, useOtel] ) const { @@ -113,14 +130,18 @@ function useLogsPreview({ queryFn: async ({ signal, pageParam }) => { const data = await executeAnalyticsSql({ projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + endpoint: logsAllEndpointUrl(useOtel), sql: defaultSql, iso_timestamp_start: params.iso_timestamp_start ?? '', iso_timestamp_end: (pageParam || params.iso_timestamp_end) ?? '', method: 'get', signal, }) - return data as unknown as Logs + const logs = data as unknown as Logs + if (useOtel && logs?.result) { + return { ...logs, result: logs.result.map(mapOtelPreviewRow) } as Logs + } + return logs }, refetchOnWindowFocus: false, initialPageParam: undefined as string | undefined, @@ -153,7 +174,10 @@ function useLogsPreview({ return { logData, error, oldestTimestamp } }, [data?.pages]) - const countQuerySql = useMemo(() => genCountQuery(table, mergedFilters), [table, mergedFilters]) + const countQuerySql = useMemo( + () => pickLogsQueryBuilder(useOtel, genCountQueryOtel, genCountQuery)(table, mergedFilters), + [useOtel, table, mergedFilters] + ) const countQueryKey = useMemo( () => [ 'projects', @@ -166,9 +190,10 @@ function useLogsPreview({ iso_timestamp_end: timestampEnd, table, mergedFilters, + otel: useOtel, }, ], - [projectRef, countQuerySql, latestRefresh, timestampEnd, table, mergedFilters] + [projectRef, countQuerySql, latestRefresh, timestampEnd, table, mergedFilters, useOtel] ) const { data: countData } = useQuery({ @@ -176,7 +201,7 @@ function useLogsPreview({ queryFn: async ({ signal }) => { const data = await executeAnalyticsSql({ projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + endpoint: logsAllEndpointUrl(useOtel), sql: countQuerySql, iso_timestamp_start: latestRefresh, iso_timestamp_end: timestampEnd ?? '', @@ -193,8 +218,9 @@ function useLogsPreview({ const newCount = countData?.result?.[0]?.count ?? 0 const chartQuery = useMemo( - () => genChartQuery(table, params, mergedFilters), - [table, params, mergedFilters] + () => + pickLogsQueryBuilder(useOtel, genChartQueryOtel, genChartQuery)(table, params, mergedFilters), + [useOtel, table, params, mergedFilters] ) const chartQueryKey = useMemo( () => [ @@ -206,9 +232,10 @@ function useLogsPreview({ sql: chartQuery, iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd, + otel: useOtel, }, ], - [projectRef, chartQuery, timestampStart, timestampEnd] + [projectRef, chartQuery, timestampStart, timestampEnd, useOtel] ) const { data: eventChartResponse, refetch: refreshEventChart } = useQuery({ @@ -216,7 +243,7 @@ function useLogsPreview({ queryFn: async ({ signal }) => { const data = await executeAnalyticsSql({ projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + endpoint: logsAllEndpointUrl(useOtel), sql: chartQuery, iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd ?? '', diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index 18a761a0222..e9bba8e4a30 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -138,4 +138,3 @@ export const useLogsQuery = ( setParams, } } -export default useLogsQuery diff --git a/apps/studio/hooks/analytics/useLogsUrlState.ts b/apps/studio/hooks/analytics/useLogsUrlState.ts index ea3b670faad..939efd3891b 100644 --- a/apps/studio/hooks/analytics/useLogsUrlState.ts +++ b/apps/studio/hooks/analytics/useLogsUrlState.ts @@ -1,4 +1,5 @@ import { createParser, useQueryState } from 'nuqs' +import { useCallback } from 'react' import { Filters } from '@/components/interfaces/Settings/Logs/Logs.types' @@ -67,34 +68,55 @@ export function useLogsUrlState() { const selectedLogId = selectedLogIdValue || null const filters = filtersValue || defaultState.filters - const setSearch = (value: string) => { - setSearchValue(value || defaultState.search) - } + const setSearch = useCallback( + (value: string) => { + setSearchValue(value || defaultState.search) + }, + [setSearchValue] + ) - const setTimeRange = (start: string, end: string) => { - setTimestampStartValue(start || defaultState.timestampStart) - setTimestampEndValue(end || defaultState.timestampEnd) - } + const setTimeRange = useCallback( + (start: string, end: string) => { + setTimestampStartValue(start || defaultState.timestampStart) + setTimestampEndValue(end || defaultState.timestampEnd) + }, + [setTimestampStartValue, setTimestampEndValue] + ) - const setSelectedLogId = (value: string | null) => { - setSelectedLogIdValue(value || '') - } + const setSelectedLogId = useCallback( + (value: string | null) => { + setSelectedLogIdValue(value || '') + }, + [setSelectedLogIdValue] + ) - const setFilters = (value: Filters) => { - setFiltersValue(value || defaultState.filters) - } + const setFilters = useCallback( + (value: Filters) => { + setFiltersValue(value || defaultState.filters) + }, + [setFiltersValue] + ) - const updateFilter = (key: string, value: any) => { - setFilters({ ...filters, [key]: value }) - } + const updateFilter = useCallback( + (key: string, value: any) => { + setFilters({ ...filters, [key]: value }) + }, + [filters, setFilters] + ) - const reset = () => { + const reset = useCallback(() => { setSearch(defaultState.search) setTimestampStartValue(defaultState.timestampStart) setTimestampEndValue(defaultState.timestampEnd) setSelectedLogIdValue('') setFiltersValue(defaultState.filters) - } + }, [ + setSearch, + setTimestampStartValue, + setTimestampEndValue, + setSelectedLogIdValue, + setFiltersValue, + ]) return { // State diff --git a/apps/studio/hooks/analytics/useSingleLog.tsx b/apps/studio/hooks/analytics/useSingleLog.tsx index 136194cee68..1ea50e7b2d2 100644 --- a/apps/studio/hooks/analytics/useSingleLog.tsx +++ b/apps/studio/hooks/analytics/useSingleLog.tsx @@ -1,4 +1,6 @@ import { useQuery } from '@tanstack/react-query' +import { useFlag } from 'common' +import { useMemo } from 'react' import { LOGS_TABLES } from '@/components/interfaces/Settings/Logs/Logs.constants' import type { @@ -8,7 +10,12 @@ import type { QueryType, } from '@/components/interfaces/Settings/Logs/Logs.types' import { genSingleLogQuery } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { + genSingleLogQueryOtel, + mapOtelSingleLogToLegacy, +} from '@/components/interfaces/Settings/Logs/Logs.utils.otel' import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' import { safeSql } from '@/data/logs/safe-analytics-sql' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' @@ -32,7 +39,23 @@ function useSingleLog({ paramsToMerge, }: SingleLogParams): SingleLogHook { const table = queryType ? LOGS_TABLES[queryType] : undefined - const sql = id && table ? genSingleLogQuery(table, id) : safeSql`` + + // When on, fetch the log from the OTEL endpoint instead of BigQuery. + const useOtel = useFlag('otelLegacyLogs') + const endpoint = logsAllEndpointUrl(useOtel) + + const sql = useMemo(() => { + if (!id || !table) return safeSql`` + if (useOtel) { + try { + return genSingleLogQueryOtel(id) + } catch { + // Malformed (non-uuid) id — emit nothing rather than throwing in render. + return safeSql`` + } + } + return genSingleLogQuery(table, id) + }, [id, table, useOtel]) const enabled = Boolean(id && table) @@ -56,11 +79,12 @@ function useSingleLog({ queryType, paramsToMerge?.iso_timestamp_start, paramsToMerge?.iso_timestamp_end, + { otel: useOtel }, ], queryFn: async ({ signal }) => { const data = await executeAnalyticsSql({ projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + endpoint, sql, iso_timestamp_start: paramsToMerge?.iso_timestamp_start ?? '', iso_timestamp_end: paramsToMerge?.iso_timestamp_end ?? '', @@ -76,7 +100,8 @@ function useSingleLog({ }) let error: null | string | object = rcError ? (rcError as any).message : null - const result = data?.result ? data.result[0] : undefined + const rawResult = data?.result ? data.result[0] : undefined + const result = rawResult && useOtel ? mapOtelSingleLogToLegacy(rawResult, queryType) : rawResult return { data: !!result diff --git a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts index d663866fba4..cded29e609f 100644 --- a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts +++ b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts @@ -280,5 +280,3 @@ export const useEdgeFunctionsDiff = ({ clearDiffsOptimistically, } } - -export default useEdgeFunctionsDiff diff --git a/apps/studio/hooks/misc/useCheckEntitlements.ts b/apps/studio/hooks/misc/useCheckEntitlements.ts index 9d4765c7dbf..5e749b3c7cd 100644 --- a/apps/studio/hooks/misc/useCheckEntitlements.ts +++ b/apps/studio/hooks/misc/useCheckEntitlements.ts @@ -69,7 +69,7 @@ export function useHasEntitlementAccess(organizationSlug?: string) { return useCallback( (key: string) => IS_PLATFORM - ? (entitlementsData?.entitlements.find((e) => e.feature.key === key)?.hasAccess ?? false) + ? (entitlementsData?.entitlements?.find((e) => e.feature.key === key)?.hasAccess ?? false) : true, [entitlementsData] ) @@ -107,7 +107,7 @@ export function useCheckEntitlements( // If no organization slug, no access if (!finalOrgSlug) return { entitlement: null } - const entitlement = entitlementsData?.entitlements.find( + const entitlement = entitlementsData?.entitlements?.find( (entitlement) => entitlement.feature.key === featureKey ) diff --git a/apps/studio/hooks/misc/useHighAvailability.constants.ts b/apps/studio/hooks/misc/useHighAvailability.constants.ts new file mode 100644 index 00000000000..dfcfd795668 --- /dev/null +++ b/apps/studio/hooks/misc/useHighAvailability.constants.ts @@ -0,0 +1,5 @@ +export const MULTIGRES_SCHEMA_NAME = 'multigres' + +export function resolveHighAvailability(project?: { high_availability?: boolean | null }) { + return project?.high_availability ?? false +} diff --git a/apps/studio/hooks/misc/useHighAvailability.ts b/apps/studio/hooks/misc/useHighAvailability.ts new file mode 100644 index 00000000000..19beae75764 --- /dev/null +++ b/apps/studio/hooks/misc/useHighAvailability.ts @@ -0,0 +1,25 @@ +import { MULTIGRES_SCHEMA_NAME, resolveHighAvailability } from './useHighAvailability.constants' +import { useSelectedProjectQuery } from './useSelectedProject' + +export { MULTIGRES_SCHEMA_NAME, resolveHighAvailability } + +export function useHighAvailability() { + const { data: project, isPending } = useSelectedProjectQuery() + + const isHighAvailability = resolveHighAvailability(project) + + return { + isHighAvailability, + isHighAvailabilityDisabled: !isHighAvailability, + isPending, + } +} + +export function filterSchemasForHighAvailability<T extends { name: string }>( + schemas: T[], + isHighAvailability: boolean +) { + if (!isHighAvailability) return schemas + + return schemas.filter((schema) => schema.name !== MULTIGRES_SCHEMA_NAME) +} diff --git a/apps/studio/hooks/misc/useInterval.ts b/apps/studio/hooks/misc/useInterval.ts deleted file mode 100644 index 3e72cd2aca2..00000000000 --- a/apps/studio/hooks/misc/useInterval.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useRef } from 'react' - -export function useInterval(callback: () => void, delay: number | false) { - const savedCallback = useRef(callback) - - useEffect(() => { - savedCallback.current = callback - }, [callback]) - - useEffect(() => { - if (delay === false) { - return - } - - const id = setInterval(() => { - savedCallback.current() - }, delay) - - return () => { - clearInterval(id) - } - }, [delay]) -} diff --git a/apps/studio/hooks/misc/useLatest.ts b/apps/studio/hooks/misc/useLatest.ts index dd29313d4e8..4ab463c05d9 100644 --- a/apps/studio/hooks/misc/useLatest.ts +++ b/apps/studio/hooks/misc/useLatest.ts @@ -5,5 +5,3 @@ export const useLatest = <T>(value: T): { readonly current: T } => { ref.current = value return ref } - -export default useLatest diff --git a/apps/studio/hooks/misc/useSchemasForAi.ts b/apps/studio/hooks/misc/useSchemasForAi.ts deleted file mode 100644 index 6bc31602c38..00000000000 --- a/apps/studio/hooks/misc/useSchemasForAi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { LOCAL_STORAGE_KEYS } from 'common' -import { Dispatch, SetStateAction } from 'react' - -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' - -/** - * Returns which schemas are ok to be sent to AI. - */ -export function useSchemasForAi( - ref: string -): readonly [string[], Dispatch<SetStateAction<string[]>>] { - const [enabledSchemas, setEnabledSchemas] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_SCHEMA(ref), - ['public'] - ) - - if (!ref) return [[], () => {}] - - return [enabledSchemas, setEnabledSchemas] -} diff --git a/apps/studio/hooks/misc/withAuth.tsx b/apps/studio/hooks/misc/withAuth.tsx index 0088f9f9a08..b480149fbf9 100644 --- a/apps/studio/hooks/misc/withAuth.tsx +++ b/apps/studio/hooks/misc/withAuth.tsx @@ -67,13 +67,18 @@ export function withAuth<T>( const isLoggedIn = Boolean(session) const isFinishedLoading = !isLoading && !isAALLoading + const isCorrectLevel = options.useHighestAAL + ? aalData?.currentLevel === aalData?.nextLevel + : true + const needsMfaElevation = isLoggedIn && !isCorrectLevel + const redirectToSignIn = useCallback(() => { let pathname = location.pathname if (BASE_PATH) { pathname = pathname.replace(BASE_PATH, '') } - if (pathname === '/sign-in') { + if (pathname === '/sign-in' || pathname === '/sign-in-mfa') { // If the user is already on the sign in page, we don't need to redirect them return } @@ -81,11 +86,19 @@ export function withAuth<T>( const searchParams = new URLSearchParams(location.search) searchParams.set('returnTo', pathname) + if (needsMfaElevation) { + // Session is valid at AAL1 but needs to be elevated to AAL2. Preserve the session + // and send the user to the MFA challenge — typically the IdP-initiated SSO path, + // where the user lands directly on /dashboard without going through /sign-in-mfa. + router.push(`/sign-in-mfa?${searchParams.toString()}`) + return + } + // Sign out before redirecting to sign in page incase the user is stuck in a loading state signOut().finally(() => { router.push(`/sign-in?${searchParams.toString()}`) }) - }, [router, signOut]) + }, [router, signOut, needsMfaElevation]) useEffect(() => { if (!isFinishedLoading) { @@ -106,10 +119,6 @@ export function withAuth<T>( } }, [isFinishedLoading, router, redirectToSignIn]) - const isCorrectLevel = options.useHighestAAL - ? aalData?.currentLevel === aalData?.nextLevel - : true - const shouldRedirect = isFinishedLoading && (!isLoggedIn || !isCorrectLevel) useEffect(() => { diff --git a/apps/studio/hooks/ui/useConfirmOnClose.tsx b/apps/studio/hooks/ui/useConfirmOnClose.tsx index 7e19dc2a61b..576827463a8 100644 --- a/apps/studio/hooks/ui/useConfirmOnClose.tsx +++ b/apps/studio/hooks/ui/useConfirmOnClose.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react' -import useLatest from '../misc/useLatest' +import { useLatest } from '../misc/useLatest' export interface ConfirmOnCloseModalProps { visible: boolean diff --git a/apps/studio/hooks/ui/useUrlState.ts b/apps/studio/hooks/ui/useUrlState.ts index 919b5f2a843..3c9543e9eb8 100644 --- a/apps/studio/hooks/ui/useUrlState.ts +++ b/apps/studio/hooks/ui/useUrlState.ts @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' export type UrlStateParams = { [k: string]: string | string[] | undefined diff --git a/apps/studio/hooks/use-check-latest-deploy.tsx b/apps/studio/hooks/use-check-latest-deploy.tsx index f695da8f0b4..8e1d4a073a7 100644 --- a/apps/studio/hooks/use-check-latest-deploy.tsx +++ b/apps/studio/hooks/use-check-latest-deploy.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import { Button, StatusIcon } from 'ui' import { useDeploymentCommitQuery } from '@/data/utils/deployment-commit-query' +import { BASE_PATH } from '@/lib/constants' const DeployCheckToast = ({ id }: { id: string | number }) => { const router = useRouter() @@ -24,7 +25,16 @@ const DeployCheckToast = ({ id }: { id: string | number }) => { <Button variant="outline" onClick={() => toast.dismiss(id)}> Not now </Button> - <Button onClick={() => router.reload()}>Refresh</Button> + <Button + onClick={() => { + // Clear the deployment pin so the reload lands on the latest + // version. No-op on Next (the cookie isn't set there). + document.cookie = `__vdpl=; Path=${BASE_PATH || '/'}; Max-Age=0` + router.reload() + }} + > + Refresh + </Button> </div> </div> ) diff --git a/apps/studio/lib/ai/model.utils.ts b/apps/studio/lib/ai/model.utils.ts index 905c650dff3..0174c2904ec 100644 --- a/apps/studio/lib/ai/model.utils.ts +++ b/apps/studio/lib/ai/model.utils.ts @@ -51,6 +51,11 @@ export const DEFAULT_COMPLETION_MODEL = openaiModelEntry({ reasoningEffort: 'none', }) +export const LOGS_REWRITE_MODEL = openaiModelEntry({ + id: 'gpt-5.4-nano', + reasoningEffort: 'low', +}) + // Single source of truth for all Assistant chat model variants and their reasoning levels. // Models with requiresAdvanceModelEntitlement false are available to all users; true requires the assistant.advance_model entitlement. export const ASSISTANT_MODELS = [ diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index 81c6f0435f6..3a74b8b6dcc 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -786,6 +786,15 @@ export const SQL_COMPLETION_INSTRUCTIONS = ` Do not quote identifiers unless they actually require it (uppercase letters, reserved words, or special characters). Plain lowercase identifiers should not be quoted. ` +export const CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS = ` +# Supabase logs SQL (ClickHouse) +You are writing SQL for Supabase logs, which run on a ClickHouse-backed engine. This is NOT Postgres and NOT BigQuery. Output valid ClickHouse SQL only. +- All logs are in a single table named \`logs\`, keyed by a \`source\` column. There are no per-service tables (no \`edge_logs\`, \`postgres_logs\`, and so on) and no \`unnest\` joins. +- Per-source fields live in the \`log_attributes\` Map(String, String), read as \`log_attributes['key']\`. Map values are strings, so wrap numeric ones in \`toInt32OrZero(...)\`. +- Use ClickHouse functions, not Postgres or BigQuery ones. Use \`match(col, 'regex')\` or \`col ILIKE '%text%'\` instead of \`regexp_contains\`, \`count()\` instead of \`count(*)\`, and select the \`timestamp\` column directly instead of \`cast(timestamp as datetime)\`. +- Do not quote identifiers with double quotes and do not append a trailing semicolon. +` + export const LIMITATIONS_PROMPT = ` # Limitations - You are to only answer Supabase, database, or edge function related questions. All other questions should be declined with a polite message. diff --git a/apps/studio/lib/ai/tools/fallback-tools.ts b/apps/studio/lib/ai/tools/fallback-tools.ts index d2f7819d178..2a3ed2329a6 100644 --- a/apps/studio/lib/ai/tools/fallback-tools.ts +++ b/apps/studio/lib/ai/tools/fallback-tools.ts @@ -1,6 +1,5 @@ import { getEntityDefinitionsSql } from '@supabase/pg-meta' import { tool } from 'ai' -// import { processSql, renderSupabaseJs } from '@supabase/sql-to-rest' import { IS_PLATFORM } from 'common' import { stripIndent } from 'common-tags' import { z } from 'zod' diff --git a/apps/studio/lib/ai/util.ts b/apps/studio/lib/ai/util.ts index 953f1e58227..21b47997f7e 100644 --- a/apps/studio/lib/ai/util.ts +++ b/apps/studio/lib/ai/util.ts @@ -32,7 +32,7 @@ export async function selectWeightedKey<T extends string>( const keys = Object.keys(weights) as T[] const encoder = new TextEncoder() const data = encoder.encode(input) - const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashBuffer = await crypto.subtle.digest('SHA-256', data as BufferSource) // Use first 4 bytes (32 bit integer) const hashInt = new DataView(hashBuffer).getUint32(0) diff --git a/apps/studio/lib/api/self-hosted/constants.ts b/apps/studio/lib/api/self-hosted/constants.ts index 6e4f10f8b13..8df9f1549d7 100644 --- a/apps/studio/lib/api/self-hosted/constants.ts +++ b/apps/studio/lib/api/self-hosted/constants.ts @@ -2,8 +2,7 @@ // Schemas exposed via PostgREST Data API, read from the PGRST_DB_SCHEMAS env var // that is passed to the Studio container via docker-compose / CLI. -export const DEFAULT_EXPOSED_SCHEMAS = - process.env.PGRST_DB_SCHEMAS ?? 'public,storage,graphql_public' +export const DEFAULT_EXPOSED_SCHEMAS = process.env.PGRST_DB_SCHEMAS ?? 'public,graphql_public' export const ENCRYPTION_KEY = process.env.PG_META_CRYPTO_KEY || 'SAMPLE_KEY' export const POSTGRES_PORT = parseInt(process.env.POSTGRES_PORT || '5432', 10) diff --git a/apps/studio/lib/api/self-hosted/generate-types.test.ts b/apps/studio/lib/api/self-hosted/generate-types.test.ts index 69cc822137f..5d0c70d49df 100644 --- a/apps/studio/lib/api/self-hosted/generate-types.test.ts +++ b/apps/studio/lib/api/self-hosted/generate-types.test.ts @@ -14,6 +14,14 @@ vi.mock('./util', () => ({ assertSelfHosted: vi.fn(), })) +// A distinctive, non-default value so the assertions verify the URL reflects the +// configured exposed schemas (PGRST_DB_SCHEMAS) — whatever they are — rather than +// coupling the test to a specific default. +const EXPOSED_SCHEMAS = 'public,custom_schema,graphql_public' +vi.mock('./constants', () => ({ + DEFAULT_EXPOSED_SCHEMAS: 'public,custom_schema,graphql_public', +})) + describe('api/self-hosted/generate-types', () => { let mockFetchGet: ReturnType<typeof vi.fn> let mockAssertSelfHosted: ReturnType<typeof vi.fn> @@ -37,57 +45,17 @@ describe('api/self-hosted/generate-types', () => { expect(mockAssertSelfHosted).toHaveBeenCalled() }) - it('should fetch from correct URL with schema params', async () => { + it('should request types for the configured exposed schemas', async () => { mockFetchGet.mockResolvedValue({ types: 'export type User = {}' }) await generateTypescriptTypes({ headers: {} }) - expect(mockFetchGet).toHaveBeenCalledWith( - expect.stringContaining('http://localhost:8080/generators/typescript'), - expect.any(Object) - ) - const callUrl = mockFetchGet.mock.calls[0][0] - expect(callUrl).toContain('included_schema=public,graphql_public,storage') - expect(callUrl).toContain('excluded_schemas=') - }) - - it('should include correct schemas in URL', async () => { - mockFetchGet.mockResolvedValue({ types: '' }) - - await generateTypescriptTypes({ headers: {} }) - - const callUrl = mockFetchGet.mock.calls[0][0] - expect(callUrl).toContain('public') - expect(callUrl).toContain('graphql_public') - expect(callUrl).toContain('storage') - }) - - it('should exclude system schemas', async () => { - mockFetchGet.mockResolvedValue({ types: '' }) - - await generateTypescriptTypes({ headers: {} }) - - const callUrl = mockFetchGet.mock.calls[0][0] - const excludedSchemas = [ - 'auth', - 'cron', - 'extensions', - 'graphql', - 'net', - 'pgsodium', - 'pgsodium_masks', - 'realtime', - 'supabase_functions', - 'supabase_migrations', - 'vault', - '_analytics', - '_realtime', - ] - - excludedSchemas.forEach((schema) => { - expect(callUrl).toContain(schema) - }) + expect(callUrl).toContain('http://localhost:8080/generators/typescript') + // Forwards the exposed-schemas config verbatim as the plural allowlist param. + expect(callUrl).toContain(`included_schemas=${EXPOSED_SCHEMAS}`) + // No hardcoded exclude list — the exposed-schemas config is the source of truth. + expect(callUrl).not.toContain('excluded_schemas=') }) it('should pass headers to fetchGet', async () => { @@ -128,15 +96,5 @@ describe('api/self-hosted/generate-types', () => { expect(mockFetchGet).toHaveBeenCalledWith(expect.any(String), { headers: undefined }) }) - - it('should construct URL with both included and excluded schemas', async () => { - mockFetchGet.mockResolvedValue({ types: '' }) - - await generateTypescriptTypes({ headers: {} }) - - const callUrl = mockFetchGet.mock.calls[0][0] - expect(callUrl).toContain('included_schema=') - expect(callUrl).toContain('excluded_schemas=') - }) }) }) diff --git a/apps/studio/lib/api/self-hosted/generate-types.ts b/apps/studio/lib/api/self-hosted/generate-types.ts index 76e8068200e..8dac69c89eb 100644 --- a/apps/studio/lib/api/self-hosted/generate-types.ts +++ b/apps/studio/lib/api/self-hosted/generate-types.ts @@ -1,3 +1,4 @@ +import { DEFAULT_EXPOSED_SCHEMAS } from './constants' import { assertSelfHosted } from './util' import { fetchGet } from '@/data/fetchers' import { PG_META_URL } from '@/lib/constants' @@ -21,26 +22,12 @@ export async function generateTypescriptTypes({ }: GenerateTypescriptTypesOptions): Promise<GenerateTypescriptTypesResult | ResponseError> { assertSelfHosted() - const includedSchema = ['public', 'graphql_public', 'storage'].join(',') - - const excludedSchema = [ - 'auth', - 'cron', - 'extensions', - 'graphql', - 'net', - 'pgsodium', - 'pgsodium_masks', - 'realtime', - 'supabase_functions', - 'supabase_migrations', - 'vault', - '_analytics', - '_realtime', - ].join(',') - + // Use the schemas actually exposed via PostgREST (PGRST_DB_SCHEMAS) so generated + // types match the Data API surface, instead of a hardcoded include/exclude list. + // Note the param is `included_schemas` (plural) — pg-meta treats a non-empty list + // as a strict allowlist; the singular spelling is silently ignored (includes all). const response = await fetchGet<GenerateTypescriptTypesResult>( - `${PG_META_URL}/generators/typescript?included_schema=${includedSchema}&excluded_schemas=${excludedSchema}`, + `${PG_META_URL}/generators/typescript?included_schemas=${DEFAULT_EXPOSED_SCHEMAS}`, { headers } ) diff --git a/apps/studio/lib/api/snippets.utils.test.ts b/apps/studio/lib/api/snippets.utils.test.ts index f218ada61ae..7cff947defc 100644 --- a/apps/studio/lib/api/snippets.utils.test.ts +++ b/apps/studio/lib/api/snippets.utils.test.ts @@ -238,6 +238,23 @@ describe('snippets.utils', () => { await expect(getFilesystemEntries()).rejects.toThrow('Permission denied') }) + + it('should skip reading file content when includeContent is false', async () => { + mockedFS.access.mockResolvedValue(undefined) + mockedFS.readdir.mockResolvedValue([ + { name: 'snippet1.sql', isDirectory: () => false, isFile: () => true }, + { name: 'snippet2.sql', isDirectory: () => false, isFile: () => true }, + ] as any) + mockedFS.stat.mockResolvedValue({ birthtime: new Date('2023-01-01') } as any) + + const entries = await getFilesystemEntries({ includeContent: false }) + + const files = entries.filter((e) => e.type === 'file') + expect(files).toHaveLength(2) + expect(files.every((f) => f.content === undefined)).toBe(true) + // Listings should never touch file contents off disk. + expect(mockedFS.readFile).not.toHaveBeenCalled() + }) }) describe('getSnippet', () => { @@ -658,6 +675,45 @@ describe('snippets.utils', () => { expect(result.snippets).toHaveLength(1) }) + + it('should omit content but keep metadata when includeContent is false', async () => { + mockedFS.access.mockResolvedValue(undefined) + mockedFS.readdir.mockResolvedValue([ + { name: 'test-snippet.sql', isDirectory: () => false, isFile: () => true }, + ] as any) + mockedFS.stat.mockResolvedValue({ birthtime: new Date('2023-01-01T10:00:00Z') } as any) + + const result = await getSnippets({ includeContent: false }) + + expect(result.snippets).toHaveLength(1) + const snippet = result.snippets[0] + + // Matches the Management API folder-listing contract: metadata, no SQL body. + expect(snippet).not.toHaveProperty('content') + expect(snippet).toMatchObject({ + name: 'test-snippet', + type: 'sql', + visibility: 'user', + folder_id: null, + }) + expect(snippet.id).toBeDefined() + // Listing must not read file contents off disk. + expect(mockedFS.readFile).not.toHaveBeenCalled() + }) + + it('searches by name without reading content when includeContent is false', async () => { + mockedFS.access.mockResolvedValue(undefined) + mockedFS.readdir.mockResolvedValue([ + { name: 'user-query.sql', isDirectory: () => false, isFile: () => true }, + { name: 'admin-report.sql', isDirectory: () => false, isFile: () => true }, + ] as any) + mockedFS.stat.mockResolvedValue({ birthtime: new Date('2023-01-01') } as any) + + const result = await getSnippets({ searchTerm: 'user', includeContent: false }) + + expect(result.snippets.map((s) => s.name)).toEqual(['user-query']) + expect(mockedFS.readFile).not.toHaveBeenCalled() + }) }) describe('saveSnippet', () => { diff --git a/apps/studio/lib/api/snippets.utils.ts b/apps/studio/lib/api/snippets.utils.ts index 133a7d63b3b..e0361847fa3 100644 --- a/apps/studio/lib/api/snippets.utils.ts +++ b/apps/studio/lib/api/snippets.utils.ts @@ -69,35 +69,46 @@ export type FilesystemEntry = { createdAt: Date } +/** + * Snippet without its SQL body — the shape returned for listings, matching the Management API + * contract (`GetUserContentFolderResponse.data.contents[]` has no `content`). The body is loaded + * on demand via the item endpoint (`getSnippet`). + */ +export type SnippetMetadata = Omit<Snippet, 'content'> + +const buildSnippetMetadata = ( + filename: string, + folderId: string | null, + createdAt: Date +): SnippetMetadata => ({ + id: generateDeterministicUuid([folderId, `${filename}.sql`]), + inserted_at: createdAt.toISOString(), + updated_at: createdAt.toISOString(), + type: 'sql', + name: filename.replace('.sql', ''), + description: '', + favorite: false, + visibility: 'user', + project_id: 1, + folder_id: folderId, + owner_id: 1, + owner: { id: 1, username: 'johndoe' }, + updated_by: { id: 1, username: 'johndoe' }, +}) + const buildSnippet = ( filename: string, content: string, folderId: string | null, createdAt: Date -) => { - const snippet: Snippet = { - id: generateDeterministicUuid([folderId, `${filename}.sql`]), - inserted_at: createdAt.toISOString(), - updated_at: createdAt.toISOString(), - type: 'sql', - name: filename.replace('.sql', ''), - description: '', - favorite: false, - content: { - sql: content, - content_id: uuidv4(), - schema_version: '1.0', - }, - visibility: 'user', - project_id: 1, - folder_id: folderId, - owner_id: 1, - owner: { id: 1, username: 'johndoe' }, - updated_by: { id: 1, username: 'johndoe' }, - } - - return snippet -} +): Snippet => ({ + ...buildSnippetMetadata(filename, folderId, createdAt), + content: { + sql: content, + content_id: uuidv4(), + schema_version: '1.0', + }, +}) const buildFolder = (name: string) => { const folder: Folder = { @@ -121,10 +132,14 @@ const sanitizeName = (name: string): string => { } /** - * Gets a complete snapshot of the filesystem structure including files and folders + * Gets a snapshot of the filesystem structure including files and folders. + * Pass `includeContent: false` for listings (sidebar/folder tree), which only need metadata — + * this skips reading every `.sql` file off disk. * @returns An array of files and folders with their metadata */ -export async function getFilesystemEntries(): Promise<FilesystemEntry[]> { +export async function getFilesystemEntries({ + includeContent = true, +}: { includeContent?: boolean } = {}): Promise<FilesystemEntry[]> { if (SNIPPETS_DIR === '') { throw new Error( 'SNIPPETS_MANAGEMENT_FOLDER env var is not set. Please set it to use snippets properly.' @@ -171,7 +186,7 @@ export async function getFilesystemEntries(): Promise<FilesystemEntry[]> { await readEntriesRecursively(itemPath, item.name) } else if (item.isFile() && item.name.endsWith('.sql')) { const [content, stats] = await Promise.all([ - fs.readFile(itemPath, 'utf-8'), + includeContent ? fs.readFile(itemPath, 'utf-8') : Promise.resolve(undefined), fs.stat(itemPath), ]) const snippetName = item.name.replace('.sql', '') @@ -181,7 +196,7 @@ export async function getFilesystemEntries(): Promise<FilesystemEntry[]> { name: snippetName, type: 'file', folderId: folderId, - content: content, + content, createdAt: stats.birthtime, }) } @@ -208,24 +223,42 @@ export const getSnippet = async (snippetId: string) => { ) } -/** - * Gets a filtered paginated list of snippets based on the provided criteria - */ -export const getSnippets = async ({ - searchTerm, - limit, - cursor, - sort, - sortOrder, - folderId, -}: { +interface GetSnippetsParams { searchTerm?: string limit?: number cursor?: string sortOrder?: 'asc' | 'desc' sort?: 'name' | 'inserted_at' folderId?: string | null -}): Promise<{ cursor: string | undefined; snippets: Snippet[] }> => { + /** + * Listings (sidebar/folder tree) only need metadata; the SQL body is loaded on demand via the + * item endpoint. Pass `false` to omit `content` and skip reading files off disk. Defaults to + * `true` for the flat `/content` endpoint, which the contract returns with content. + */ + includeContent?: boolean +} + +/** + * Gets a filtered paginated list of snippets based on the provided criteria + */ +export function getSnippets( + params: GetSnippetsParams & { includeContent: false } +): Promise<{ cursor: string | undefined; snippets: SnippetMetadata[] }> +export function getSnippets( + params?: GetSnippetsParams & { includeContent?: true } +): Promise<{ cursor: string | undefined; snippets: Snippet[] }> +export async function getSnippets({ + searchTerm, + limit, + cursor, + sort, + sortOrder, + folderId, + includeContent = true, +}: GetSnippetsParams = {}): Promise<{ + cursor: string | undefined + snippets: Snippet[] | SnippetMetadata[] +}> { // Normalize and set default values const normalizedSearchTerm = searchTerm?.trim() ?? '' const normalizedLimit = limit ?? 100 @@ -242,10 +275,9 @@ export const getSnippets = async ({ throw new Error('Limit cannot exceed 1000') } - const entries = await getFilesystemEntries() + const entries = await getFilesystemEntries({ includeContent }) const files = entries.filter( - (entry): entry is FilesystemEntry & { type: 'file'; content: string } => - entry.type === 'file' && entry.content !== undefined && entry.content !== null + (entry): entry is FilesystemEntry & { type: 'file' } => entry.type === 'file' ) // Filter snippets based on search term or folder @@ -294,7 +326,9 @@ export const getSnippets = async ({ return { cursor: nextCursor, snippets: finalSnippets.map((file) => - buildSnippet(file.name, file.content, file.folderId, file.createdAt) + includeContent + ? buildSnippet(file.name, file.content ?? '', file.folderId, file.createdAt) + : buildSnippetMetadata(file.name, file.folderId, file.createdAt) ), } } @@ -410,7 +444,7 @@ export async function updateSnippet(id: string, updates: DeepPartial<Snippet>): } export const getFolders = async (folderId: string | null = null): Promise<Folder[]> => { - const entries = await getFilesystemEntries() + const entries = await getFilesystemEntries({ includeContent: false }) const folders = entries .filter( (entry): entry is FilesystemEntry & { type: 'folder' } => diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index 9a0503fb7de..6da86976f6d 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -70,7 +70,10 @@ export const PROVIDERS = { AWS_NIMBUS: { id: 'AWS_NIMBUS', name: 'AWS (Nimbus)', - default_region: AWS_REGIONS_DEFAULT, + default_region: + process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + ? AWS_REGIONS.SOUTHEAST_ASIA + : AWS_REGIONS.EAST_US, regions: { ...AWS_REGIONS }, }, } as const diff --git a/apps/studio/lib/hosted-api-allowlist.ts b/apps/studio/lib/hosted-api-allowlist.ts new file mode 100644 index 00000000000..614d863ddc3 --- /dev/null +++ b/apps/studio/lib/hosted-api-allowlist.ts @@ -0,0 +1,37 @@ +// [Joshen] Allowlist of API endpoints supported in hosted (platform) mode. +// Every other /api/* route must 404 in platform mode. Shared by the Next +// middleware (proxy.ts) and the TanStack request middleware (start.ts) so +// the list can't drift between the two frameworks while both run in parallel. +export const HOSTED_SUPPORTED_API_URLS = [ + '/ai/sql/generate-v4', + '/ai/sql/policy', + '/ai/feedback/rate', + '/ai/code/complete', + '/ai/sql/cron-v2', + '/ai/sql/title-v2', + '/ai/sql/filter-v1', + '/ai/onboarding/design', + '/ai/feedback/classify', + '/ai/docs', + '/ai/sql/parse-client-code', + '/get-ip-address', + '/get-utc-time', + '/get-deployment-commit', + '/check-cname', + '/edge-functions/test', + '/edge-functions/body', + '/generate-attachment-url', + '/incident-status', + '/incident-banner', + '/status-override', + '/api/integrations/stripe-sync', + '/content/graphql', + '/parse-query', +] + +// `pathname` must be basePath-relative — Next's `nextUrl.pathname` already is, +// and the TanStack guard strips BASE_PATH before calling. Entries are path +// suffixes, so `endsWith` stays correct regardless. +export function isHostedSupportedApiPath(pathname: string): boolean { + return HOSTED_SUPPORTED_API_URLS.some((url) => pathname.endsWith(url)) +} diff --git a/apps/studio/lib/notifier.ts b/apps/studio/lib/notifier.ts new file mode 100644 index 00000000000..8c63ef31837 --- /dev/null +++ b/apps/studio/lib/notifier.ts @@ -0,0 +1,10 @@ +/** + * A minimal dependency-injection contract for surfacing user-facing + * notifications (toasts). It is structurally a subset of sonner's `toast`, so + * the real `toast` can be passed directly while tests pass a spy — letting + * modules that need to notify stay free of a hard sonner import. + */ +export interface Notifier { + success: (message: string) => void + error: (message: string) => void +} diff --git a/apps/studio/lib/role-impersonation.ts b/apps/studio/lib/role-impersonation.ts index 5d49df34485..0f6353f12b7 100644 --- a/apps/studio/lib/role-impersonation.ts +++ b/apps/studio/lib/role-impersonation.ts @@ -126,7 +126,7 @@ function encodeBase64Url(data: ArrayBuffer | Uint8Array | string): string { function genKey(rawKey: string) { return window.crypto.subtle.importKey( 'raw', - encodeText(rawKey), + encodeText(rawKey) as BufferSource, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'] @@ -144,7 +144,7 @@ async function createToken(jwtPayload: object, key: string) { await window.crypto.subtle.sign( { name: 'HMAC' }, await genKey(key), - encodeText(headerAndPayload) + encodeText(headerAndPayload) as BufferSource ) ) ) diff --git a/apps/studio/lib/telemetry.test.tsx b/apps/studio/lib/telemetry.test.tsx index 4d2d1656a78..3537b94fab4 100644 --- a/apps/studio/lib/telemetry.test.tsx +++ b/apps/studio/lib/telemetry.test.tsx @@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({ identify: vi.fn(), useUser: vi.fn(), useOrganizationsQuery: vi.fn(), + setUser: vi.fn(), + setTag: vi.fn(), })) vi.mock('common', async (importOriginal) => { @@ -34,7 +36,8 @@ vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ })) vi.mock('@sentry/nextjs', () => ({ - setUser: vi.fn(), + setUser: (...args: unknown[]) => mocks.setUser(...args), + setTag: (...args: unknown[]) => mocks.setTag(...args), })) const USER_ID = 'user-abc-123' @@ -48,6 +51,8 @@ describe('Telemetry — posthog identify mirroring', () => { mocks.identify.mockReset() mocks.useUser.mockReset() mocks.useOrganizationsQuery.mockReset() + mocks.setUser.mockReset() + mocks.setTag.mockReset() }) it('fires identify with both org_count and signup_timestamp when user and orgs are loaded', async () => { @@ -146,3 +151,32 @@ describe('Telemetry — posthog identify mirroring', () => { expect(mocks.identify).not.toHaveBeenCalled() }) }) + +describe('Telemetry — Sentry user identification', () => { + beforeEach(() => { + mocks.identify.mockReset() + mocks.useUser.mockReset() + mocks.useOrganizationsQuery.mockReset() + mocks.setUser.mockReset() + }) + + it('sets the raw (un-hashed) user id on Sentry', async () => { + mocks.useUser.mockReturnValue({ id: USER_ID, created_at: CREATED_AT }) + mocks.useOrganizationsQuery.mockReturnValue({ data: orgs(1) }) + + render(<Telemetry />) + + await waitFor(() => { + expect(mocks.setUser).toHaveBeenCalledWith({ id: USER_ID }) + }) + }) + + it('does not set a Sentry user when the user is not logged in', async () => { + mocks.useUser.mockReturnValue(null) + mocks.useOrganizationsQuery.mockReturnValue({ data: orgs(1) }) + + render(<Telemetry />) + await new Promise((r) => setTimeout(r, 10)) + expect(mocks.setUser).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/lib/telemetry.tsx b/apps/studio/lib/telemetry.tsx index 9ae59ef7441..cdf1a5b1c00 100644 --- a/apps/studio/lib/telemetry.tsx +++ b/apps/studio/lib/telemetry.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/nextjs' -import { LOCAL_STORAGE_KEYS, PageTelemetry, posthogClient, safeLocalStorage, useUser } from 'common' +import { PageTelemetry, posthogClient, useParams, useUser } from 'common' import { useEffect, useRef } from 'react' import { useConsentToast } from 'ui-patterns/consent' @@ -7,16 +7,6 @@ import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { API_URL, IS_PLATFORM } from '@/lib/constants' -const getAnonId = async (id: string) => { - const encoder = new TextEncoder() - const data = encoder.encode(id) - const hashBuffer = await crypto.subtle.digest('SHA-256', data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const base64String = btoa(hashArray.map((byte) => String.fromCharCode(byte)).join('')) - - return base64String -} - export function Telemetry() { // Although this is "technically" breaking the rules of hooks // IS_PLATFORM never changes within a session, so this won't cause any issues @@ -29,6 +19,9 @@ export function Telemetry() { const user = useUser() + // Project ref from the URL params, mirroring the backend's `request.params.ref` + const { ref: projectRef } = useParams() + // Mirror the user's org-list length into a PostHog person property so feature // flags and analytics can segment by current org membership. signup_timestamp // is set on the same identify so flag audiences requiring both properties see @@ -60,22 +53,16 @@ export function Telemetry() { useEffect(() => { // don't set the sentry user id if the user hasn't logged in (so that Sentry errors show null user id instead of anonymous id) - if (!user?.id) { - return + if (user?.id) { + Sentry.setUser({ id: user.id }) } - const setSentryId = async () => { - let sentryUserId = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.SENTRY_USER_ID) - if (!sentryUserId) { - sentryUserId = await getAnonId(user?.id) - safeLocalStorage.setItem(LOCAL_STORAGE_KEYS.SENTRY_USER_ID, sentryUserId) - } - Sentry.setUser({ id: sentryUserId }) - } - - // if an error happens, continue without setting a sentry id - setSentryId().catch((e) => console.error(e)) - }, [user?.id]) + // Tag Sentry events with the current project ref and customer org slug so backend/ + // frontend errors can be filtered by project / org. Passing a null value clears + // the tag, so stale values don't leak across navigation. + Sentry.setTag('project_ref', projectRef ?? null) + Sentry.setTag('org_slug', organization?.slug ?? null) + }, [user?.id, projectRef, organization?.slug]) return ( <PageTelemetry diff --git a/apps/studio/lib/telemetry/funnel-errors.test.ts b/apps/studio/lib/telemetry/funnel-errors.test.ts new file mode 100644 index 00000000000..6abe0049b6d --- /dev/null +++ b/apps/studio/lib/telemetry/funnel-errors.test.ts @@ -0,0 +1,114 @@ +import type { FieldErrors } from 'react-hook-form' +import { describe, expect, it } from 'vitest' + +import { classifyApiError, classifyStripeError, classifyValidationError } from './funnel-errors' + +describe('classifyApiError', () => { + it('classifies connection timeout as network', () => { + expect(classifyApiError('signup', { errorType: 'connection-timeout' })).toEqual({ + errorCategory: 'network', + errorReason: 'connection_timeout', + }) + }) + + it('classifies a missing status code as network_error', () => { + expect(classifyApiError('project_creation', { message: 'Failed to fetch' })).toEqual({ + errorCategory: 'network', + errorReason: 'network_error', + }) + }) + + it('classifies 429 as rate_limited regardless of message', () => { + expect(classifyApiError('signup', { code: 429, message: 'whatever' })).toEqual({ + errorCategory: 'api', + errorReason: 'rate_limited', + errorCode: 429, + }) + }) + + it('classifies 5xx as server_error', () => { + expect(classifyApiError('org_creation', { code: 500, message: 'boom' })).toEqual({ + errorCategory: 'api', + errorReason: 'server_error', + errorCode: 500, + }) + }) + + it('matches a known 4xx signup message to a reason slug', () => { + expect(classifyApiError('signup', { code: 400, message: 'User already registered' })).toEqual({ + errorCategory: 'api', + errorReason: 'email_already_registered', + errorCode: 400, + }) + }) + + it('matches a known 4xx project message to a reason slug', () => { + expect( + classifyApiError('project_creation', { + code: 403, + message: 'Your organization can only have 2 projects', + }) + ).toEqual({ errorCategory: 'api', errorReason: 'project_limit_reached', errorCode: 403 }) + }) + + it('falls back to other for an unmapped 4xx message', () => { + expect(classifyApiError('signup', { code: 400, message: 'totally novel error' })).toEqual({ + errorCategory: 'api', + errorReason: 'other', + errorCode: 400, + }) + }) +}) + +describe('classifyValidationError', () => { + it('maps a signup password error to password_invalid', () => { + expect( + classifyValidationError('signup', { password: { type: 'too_small' } } as FieldErrors) + ).toEqual({ + errorCategory: 'validation', + errorReason: 'password_invalid', + }) + }) + + it('respects field priority (email before password)', () => { + expect( + classifyValidationError('signup', { + email: { type: 'invalid' }, + password: { type: 'too_small' }, + } as FieldErrors) + ).toEqual({ errorCategory: 'validation', errorReason: 'email_invalid' }) + }) + + it('maps an org name error to org_name_missing', () => { + expect( + classifyValidationError('org_creation', { name: { type: 'too_small' } } as FieldErrors) + ).toEqual({ + errorCategory: 'validation', + errorReason: 'org_name_missing', + }) + }) + + it('falls back to other for an unmapped field', () => { + expect( + classifyValidationError('project_creation', { somethingNew: { type: 'x' } } as FieldErrors) + ).toEqual({ errorCategory: 'validation', errorReason: 'other' }) + }) +}) + +describe('classifyStripeError', () => { + it('maps a decline_code to a card reason slug', () => { + expect( + classifyStripeError({ code: 'card_declined', decline_code: 'insufficient_funds' }) + ).toEqual({ + errorCategory: 'payment', + errorReason: 'card_insufficient_funds', + }) + }) + + it('falls back to payment_failed for an unknown code', () => { + expect(classifyStripeError({ code: 'mystery' })).toEqual({ + errorCategory: 'payment', + errorReason: 'payment_failed', + }) + }) +}) diff --git a/apps/studio/lib/telemetry/funnel-errors.ts b/apps/studio/lib/telemetry/funnel-errors.ts new file mode 100644 index 00000000000..67c92592fed --- /dev/null +++ b/apps/studio/lib/telemetry/funnel-errors.ts @@ -0,0 +1,155 @@ +import type { FieldErrors } from 'react-hook-form' + +export type FunnelOrigin = 'signup' | 'project_creation' | 'org_creation' +export type ErrorCategory = 'validation' | 'api' | 'network' | 'payment' | 'unknown' + +export interface FunnelErrorClassification { + errorCategory: ErrorCategory + errorReason: FunnelErrorReason + errorCode?: number +} + +const RATE_LIMIT_STATUS = 429 + +const API_REASON_PATTERNS = { + signup: [ + [/already registered|already been registered|already exists/i, 'email_already_registered'], + [/rate limit|too many requests|after \d+ second/i, 'rate_limited'], + [/captcha/i, 'captcha_failed'], + [/password/i, 'password_rejected'], + [/valid email|invalid email|email address/i, 'email_invalid'], + ], + project_creation: [ + [/already exists/i, 'project_name_taken'], + [/free plan|free tier/i, 'free_tier_limit'], + [/limit|maximum number|can only have/i, 'project_limit_reached'], + [/payment|invoice|overdue|past due|billing/i, 'billing_issue'], + [/region/i, 'region_unavailable'], + [/db_pass|password/i, 'db_password_rejected'], + ], + org_creation: [ + [/already exists|name.*taken/i, 'org_name_taken'], + [/payment|card|invoice|billing/i, 'billing_issue'], + [/limit/i, 'org_limit_reached'], + ], +} as const satisfies Record<FunnelOrigin, ReadonlyArray<readonly [RegExp, string]>> + +const VALIDATION_FIELD_REASONS = { + signup: { + email: 'email_invalid', + password: 'password_invalid', + }, + project_creation: { + organization: 'organization_missing', + projectName: 'project_name_invalid', + dbPass: 'db_password_weak', + dbPassStrength: 'db_password_weak', + dbRegion: 'region_missing', + cloudProvider: 'cloud_provider_invalid', + postgresVersion: 'postgres_version_missing', + highAvailability: 'incompatible_options', + useOrioleDb: 'incompatible_options', + }, + org_creation: { + name: 'org_name_missing', + kind: 'org_kind_invalid', + size: 'org_size_invalid', + }, +} as const satisfies Record<FunnelOrigin, Readonly<Record<string, string>>> + +const STRIPE_DECLINE_REASONS = { + insufficient_funds: 'card_insufficient_funds', + card_declined: 'card_declined', + expired_card: 'card_expired', + incorrect_cvc: 'card_incorrect_cvc', + incorrect_number: 'card_incorrect_number', + processing_error: 'card_processing_error', +} as const satisfies Record<string, string> + +const GENERIC_REASONS = [ + 'rate_limited', + 'server_error', + 'connection_timeout', + 'network_error', + 'payment_failed', + 'payment_error', + 'oriole_unavailable', + 'unauthorized', + 'forbidden', + 'not_found', + 'other', +] as const + +type ValuesOf<T> = T extends Readonly<Record<string, infer V extends string>> ? V : never + +export type FunnelErrorReason = + | (typeof API_REASON_PATTERNS)[FunnelOrigin][number][1] + | ValuesOf<(typeof VALIDATION_FIELD_REASONS)[FunnelOrigin]> + | ValuesOf<typeof STRIPE_DECLINE_REASONS> + | (typeof GENERIC_REASONS)[number] + +const STATUS_REASONS: Readonly<Partial<Record<number, FunnelErrorReason>>> = { + 401: 'unauthorized', + 403: 'forbidden', + 404: 'not_found', +} + +export function classifyApiError(origin: FunnelOrigin, error: unknown): FunnelErrorClassification { + const err = error as { code?: unknown; errorType?: unknown; message?: unknown } + const code = typeof err?.code === 'number' ? err.code : undefined + const message = typeof err?.message === 'string' ? err.message : '' + + if (err?.errorType === 'connection-timeout') { + return { errorCategory: 'network', errorReason: 'connection_timeout' } + } + if (code === undefined) { + return { errorCategory: 'network', errorReason: 'network_error' } + } + if (code === RATE_LIMIT_STATUS) { + return { errorCategory: 'api', errorReason: 'rate_limited', errorCode: code } + } + if (code >= 500) { + return { errorCategory: 'api', errorReason: 'server_error', errorCode: code } + } + for (const [pattern, reason] of API_REASON_PATTERNS[origin]) { + if (pattern.test(message)) { + return { errorCategory: 'api', errorReason: reason, errorCode: code } + } + } + const statusReason = STATUS_REASONS[code] + if (statusReason) { + return { errorCategory: 'api', errorReason: statusReason, errorCode: code } + } + return { errorCategory: 'api', errorReason: 'other', errorCode: code } +} + +export function classifyValidationError( + origin: FunnelOrigin, + errors: FieldErrors +): FunnelErrorClassification { + const fieldErrors = errors as Record<string, unknown> + const reasons = VALIDATION_FIELD_REASONS[origin] as Readonly<Record<string, FunnelErrorReason>> + for (const field of Object.keys(reasons)) { + if (fieldErrors[field]) { + return { errorCategory: 'validation', errorReason: reasons[field] } + } + } + return { errorCategory: 'validation', errorReason: 'other' } +} + +export function classifyStripeError(error: unknown): FunnelErrorClassification { + const err = error as { code?: unknown; decline_code?: unknown } + const key = + typeof err?.decline_code === 'string' + ? err.decline_code + : typeof err?.code === 'string' + ? err.code + : undefined + const reason = key + ? (STRIPE_DECLINE_REASONS as Readonly<Record<string, FunnelErrorReason>>)[key] + : undefined + if (reason) { + return { errorCategory: 'payment', errorReason: reason } + } + return { errorCategory: 'payment', errorReason: 'payment_failed' } +} diff --git a/apps/studio/lib/telemetry/track.ts b/apps/studio/lib/telemetry/track.ts index 43dd260cb5f..88f615e7bfa 100644 --- a/apps/studio/lib/telemetry/track.ts +++ b/apps/studio/lib/telemetry/track.ts @@ -3,7 +3,7 @@ import { TelemetryEvent, TelemetryGroups } from 'common/telemetry-constants' import { useRouter } from 'next/router' import { useCallback } from 'react' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { API_URL } from '@/lib/constants' diff --git a/apps/studio/lib/telemetry/use-track-funnel-error.ts b/apps/studio/lib/telemetry/use-track-funnel-error.ts new file mode 100644 index 00000000000..3aa00091a55 --- /dev/null +++ b/apps/studio/lib/telemetry/use-track-funnel-error.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react' + +import type { FunnelErrorClassification, FunnelOrigin } from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' + +// Matches the existing dashboard_error_created capture rate; keeps PostHog volume bounded. +const SAMPLE_RATE = 0.1 + +export function useTrackFunnelError() { + const track = useTrack() + return useCallback( + ( + origin: FunnelOrigin, + classification: FunnelErrorClassification, + source: 'toast' | 'form' = 'toast' + ) => { + if (Math.random() >= SAMPLE_RATE) return + track('dashboard_error_created', { + source, + origin, + errorCategory: classification.errorCategory, + errorReason: classification.errorReason, + ...(classification.errorCode !== undefined && { errorCode: classification.errorCode }), + }) + }, + [track] + ) +} diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index bdaba9c3961..0f956022608 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -5,6 +5,12 @@ import { withSentryConfig } from '@sentry/nextjs' import type { NextConfig } from 'next' import { getCSP } from './csp' +import { + getMaintenanceRedirects, + PLATFORM_REDIRECTS, + SELF_HOSTED_REDIRECTS, + SHARED_REDIRECTS, +} from './redirects.shared' const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true', @@ -61,423 +67,15 @@ const nextConfig = { ] }, async redirects() { + // Rules live in `redirects.shared.ts` (shared with `vercel.ts`). Next + // auto-prepends `basePath` to source and destination on its own, + // except for the special `/` → basePath bounce below which opts out + // via `basePath: false`. + const isPlatform = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' + const maintenance = process.env.MAINTENANCE_MODE === 'true' return [ - ...(process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' - ? [ - { - source: '/', - has: [ - { - type: 'query' as const, - key: 'next', - value: 'new-project', - }, - ], - destination: '/new/new-project', - permanent: false, - }, - { - source: '/', - destination: '/org', - permanent: false, - }, - { - source: '/register', - destination: '/sign-up', - permanent: false, - }, - { - source: '/signup', - destination: '/sign-up', - permanent: false, - }, - { - source: '/signin', - destination: '/sign-in', - permanent: false, - }, - { - source: '/login', - destination: '/sign-in', - permanent: false, - }, - { - source: '/log-in', - destination: '/sign-in', - permanent: false, - }, - { - source: '/project/:ref/building', - destination: '/project/:ref', - permanent: false, - }, - ] - : [ - { - source: '/', - destination: '/project/default', - permanent: false, - }, - { - source: '/register', - destination: '/project/default', - permanent: false, - }, - { - source: '/signup', - destination: '/project/default', - permanent: false, - }, - { - source: '/signin', - destination: '/project/default', - permanent: false, - }, - { - source: '/login', - destination: '/project/default', - permanent: false, - }, - { - source: '/log-in', - destination: '/project/default', - permanent: false, - }, - { - source: '/project/:ref/building', - destination: '/project/:ref', - permanent: false, - }, - ]), - { - source: '/project/:ref/auth', - destination: '/project/:ref/auth/users', - permanent: true, - }, - { - source: '/project/:ref/auth/advanced', - destination: '/project/:ref/auth/performance', - permanent: true, - }, - { - source: '/project/:ref/database', - destination: '/project/:ref/database/tables', - permanent: true, - }, - { - source: '/project/:ref/database/graphiql', - destination: '/project/:ref/api/graphiql', - permanent: true, - }, - { - source: '/project/:ref/storage', - destination: '/project/:ref/storage/files', - permanent: true, - }, - { - source: '/project/:ref/storage/buckets', - destination: '/project/:ref/storage/files', - permanent: true, - }, - { - source: '/project/:ref/storage/policies', - destination: '/project/:ref/storage/files/policies', - permanent: true, - }, - { - source: '/project/:ref/storage/buckets/:bucketId', - destination: '/project/:ref/storage/files/buckets/:bucketId', - permanent: true, - }, - { - permanent: true, - source: '/project/:ref/settings/api-keys/new', - destination: '/project/:ref/settings/api-keys', - }, - { - source: '/project/:ref/settings/storage', - destination: '/project/:ref/storage/files/settings', - permanent: true, - }, - { - source: '/project/:ref/storage/settings', - destination: '/project/:ref/storage/files/settings', - permanent: true, - }, - { - source: '/project/:ref/settings/database', - destination: '/project/:ref/database/settings', - permanent: true, - }, - { - source: '/project/:ref/settings', - destination: '/project/:ref/settings/general', - permanent: true, - }, - { - source: '/project/:ref/auth/settings', - destination: '/project/:ref/auth/users', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/subscription', - has: [ - { - type: 'query', - key: 'panel', - value: 'subscriptionPlan', - }, - ], - destination: '/org/_/billing?panel=subscriptionPlan', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/subscription', - has: [ - { - type: 'query', - key: 'panel', - value: 'pitr', - }, - ], - destination: '/project/:ref/settings/addons?panel=pitr', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/subscription', - has: [ - { - type: 'query', - key: 'panel', - value: 'computeInstance', - }, - ], - destination: '/project/:ref/settings/compute-and-disk', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/subscription', - has: [ - { - type: 'query', - key: 'panel', - value: 'customDomain', - }, - ], - destination: '/project/:ref/settings/addons?panel=customDomain', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/subscription', - destination: '/org/_/billing', - permanent: true, - }, - { - permanent: true, - source: '/project/:ref/settings/jwt/signing-keys', - destination: '/project/:ref/settings/jwt', - }, - { - source: '/project/:ref/database/api-logs', - destination: '/project/:ref/logs/edge-logs', - permanent: true, - }, - { - source: '/project/:ref/database/postgres-logs', - destination: '/project/:ref/logs/postgres-logs', - permanent: true, - }, - { - source: '/project/:ref/database/postgrest-logs', - destination: '/project/:ref/logs/postgrest-logs', - permanent: true, - }, - { - source: '/project/:ref/database/pgbouncer-logs', - destination: '/project/:ref/logs/pooler-logs', - permanent: true, - }, - { - source: '/project/:ref/logs/pgbouncer-logs', - destination: '/project/:ref/logs/pooler-logs', - permanent: true, - }, - { - source: '/project/:ref/database/realtime-logs', - destination: '/project/:ref/logs/realtime-logs', - permanent: true, - }, - { - source: '/project/:ref/storage/logs', - destination: '/project/:ref/logs/storage-logs', - permanent: true, - }, - { - source: '/project/:ref/auth/logs', - destination: '/project/:ref/logs/auth-logs', - permanent: true, - }, - { - source: '/project/:ref/logs-explorer', - destination: '/project/:ref/logs/explorer', - permanent: true, - }, - { - source: '/project/:ref/sql/quickstarts', - destination: '/project/:ref/sql/examples', - permanent: true, - }, - { - source: '/org/:slug/settings', - destination: '/org/:slug/general', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/update', - destination: '/org/_/billing', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/update/free', - destination: '/org/_/billing', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/update/pro', - destination: '/org/_/billing', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/update/team', - destination: '/org/_/billing', - permanent: true, - }, - { - source: '/project/:ref/settings/billing/update/enterprise', - destination: '/org/_/billing', - permanent: true, - }, - { - permanent: true, - source: '/project/:ref/reports/linter', - destination: '/project/:ref/database/linter', - }, - { - permanent: true, - source: '/project/:ref/reports', - destination: '/project/:ref/observability', - }, - { - permanent: true, - source: '/project/:ref/reports/:path*', - destination: '/project/:ref/observability/:path*', - }, - { - permanent: true, - source: '/project/:ref/query-performance', - destination: '/project/:ref/observability/query-performance', - }, - { - permanent: true, - source: '/project/:ref/advisors/query-performance', - destination: '/project/:ref/observability/query-performance', - }, - { - permanent: true, - source: '/project/:ref/database/query-performance', - destination: '/project/:ref/observability/query-performance', - }, - { - permanent: true, - source: '/project/:ref/auth/column-privileges', - destination: '/project/:ref/database/column-privileges', - }, - { - permanent: true, - source: '/project/:ref/database/linter', - destination: '/project/:ref/database/security-advisor', - }, - { - permanent: true, - source: '/project/:ref/database/security-advisor', - destination: '/project/:ref/advisors/security', - }, - { - permanent: true, - source: '/project/:ref/database/performance-advisor', - destination: '/project/:ref/advisors/performance', - }, - { - permanent: true, - source: '/project/:ref/database/webhooks', - destination: '/project/:ref/integrations/webhooks/overview', - }, - { - permanent: true, - source: '/project/:ref/database/wrappers', - destination: '/project/:ref/integrations?category=wrapper', - }, - { - permanent: true, - source: '/project/:ref/database/cron-jobs', - destination: '/project/:ref/integrations/cron', - }, - { - permanent: true, - source: '/project/:ref/api/graphiql', - destination: '/project/:ref/integrations/graphiql', - }, - { - permanent: true, - source: '/project/:ref/settings/vault/secrets', - destination: '/project/:ref/integrations/vault/secrets', - }, - { - permanent: true, - source: '/project/:ref/settings/vault/keys', - destination: '/project/:ref/integrations/vault/keys', - }, - { - permanent: true, - source: '/project/:ref/integrations/cron-jobs', - destination: '/project/:ref/integrations/cron', - }, - { - permanent: true, - source: '/project/:ref/settings/warehouse', - destination: '/project/:ref/settings/general', - }, - { - permanent: true, - source: '/project/:ref/settings/functions', - destination: '/project/:ref/functions/secrets', - }, - { - source: '/org/:slug/invoices', - destination: '/org/:slug/billing#invoices', - permanent: true, - }, - { - source: '/projects', - destination: '/organizations', - permanent: false, - }, - { - source: '/project/:ref/settings/auth', - destination: '/project/:ref/auth/providers', - permanent: true, - }, - { - source: '/project/:ref/settings/api', - destination: '/project/:ref/integrations/data_api/overview', - permanent: false, - }, - { - source: '/project/:ref/api', - destination: '/project/:ref/integrations/data_api/docs', - permanent: false, - }, - + ...(isPlatform ? PLATFORM_REDIRECTS : SELF_HOSTED_REDIRECTS), + ...SHARED_REDIRECTS, ...(process.env.NEXT_PUBLIC_BASE_PATH?.length ? [ { @@ -488,22 +86,7 @@ const nextConfig = { }, ] : []), - - ...(process.env.MAINTENANCE_MODE === 'true' - ? [ - { - source: '/((?!maintenance|img).*)', // Redirect all paths except /maintenance and /img - destination: '/maintenance', - permanent: false, - }, - ] - : [ - { - source: '/maintenance', - destination: '/', - permanent: false, - }, - ]), + ...getMaintenanceRedirects(maintenance), ] }, async headers() { diff --git a/apps/studio/package.json b/apps/studio/package.json index 5c1c9f7e86b..6e33001d93c 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -6,9 +6,17 @@ "scripts": { "preinstall": "npx only-allow pnpm", "predev": "node scripts/clean-turbopack-cache.mjs", - "dev": "next dev -p ${STUDIO_PORT:-8082}", - "build": "next build && if [ \"$SKIP_ASSET_UPLOAD\" != \"1\" ]; then ./../../scripts/upload-static-assets.sh; fi", - "start": "next start -p 8082", + "dev": "node scripts/dispatch.js dev", + "build": "node scripts/dispatch.js build", + "start": "node scripts/dispatch.js start", + "preview": "vite preview --mode ${MODE:-production} --port 8082", + "dev:next": "next dev -p ${STUDIO_PORT:-8082}", + "build:next": "next build && if [ \"$SKIP_ASSET_UPLOAD\" != \"1\" ]; then ./../../scripts/upload-static-assets.sh; fi", + "start:next": "next start -p 8082", + "dev:tanstack": "NODE_OPTIONS=--max-old-space-size=8192 vite dev --port ${STUDIO_PORT:-8082}", + "build:tanstack": "vite build --mode ${MODE:-production} && pnpm smoke:tanstack", + "smoke:tanstack": "node scripts/smoke-server.mjs", + "start:tanstack": "node scripts/serve.js", "lint": "eslint .", "lint:ratchet": "tsx scripts/ratchet-eslint-rules.ts --rule react-hooks/exhaustive-deps --rule import/no-anonymous-default-export --rule @tanstack/query/exhaustive-deps --rule @typescript-eslint/no-explicit-any --rule no-restricted-imports --rule no-restricted-exports --rule react/no-unstable-nested-components", "clean": "rimraf node_modules tsconfig.tsbuildinfo .next .turbo", @@ -51,10 +59,11 @@ "@hookform/resolvers": "^3.1.1", "@mjackson/multipart-parser": "^0.10.1", "@modelcontextprotocol/sdk": "^1.29.0", - "@monaco-editor/react": "^4.6.0", + "@monaco-editor/react": "catalog:", "@next/bundle-analyzer": "16.2.3", "@number-flow/react": "^0.3.2", "@sentry/nextjs": "catalog:", + "@sentry/react": "^10.27.0", "@std/path": "npm:@jsr/std__path@^1.0.8", "@stripe/react-stripe-js": "6.1.0", "@stripe/stripe-js": "9.1.0", @@ -63,13 +72,17 @@ "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", "@supabase/shared-types": "0.1.88", - "@supabase/sql-to-rest": "^0.1.6", "@supabase/supabase-js": "catalog:", - "@tanstack/react-hotkeys": "^0.9.1", - "@tanstack/react-query": "^5.0.0", - "@tanstack/react-query-devtools": "^5.0.0", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.12", + "@tanstack/react-devtools": "^0.10.3", + "@tanstack/react-hotkeys": "^0.10.0", + "@tanstack/react-query": "~5.83.0", + "@tanstack/react-query-devtools": "~5.90.2", + "@tanstack/react-router": "catalog:", + "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-router-ssr-query": "^1.166.12", + "@tanstack/react-start": "catalog:", + "@tanstack/react-table": "catalog:", + "@tanstack/react-virtual": "^3.13.24", "@types/d3-geo": "^3.1.0", "@uidotdev/usehooks": "^2.4.1", "@vercel/functions": "^2.1.0", @@ -96,15 +109,13 @@ "http-status": "^2.1.0", "icons": "workspace:*", "idb": "^8.0.2", - "immutability-helper": "^3.1.1", "ip-num": "^1.5.1", "json-logic-js": "^2.0.2", "lodash": "catalog:", "lucide-react": "^0.436.0", "markdown-table": "^3.0.3", - "memoize-one": "^5.0.1", "mime-db": "^1.53.0", - "monaco-editor": "0.52.2", + "monaco-editor": "catalog:", "next": "catalog:", "next-themes": "catalog:", "nuqs": "2.7.1", @@ -112,7 +123,6 @@ "openapi-fetch": "0.12.4", "papaparse": "^5.3.1", "path-to-regexp": "^8.0.0", - "pg-minify": "^1.6.3", "radix-ui": "catalog:", "randombytes": "^2.1.0", "react": "catalog:", @@ -133,7 +143,6 @@ "shared-data": "workspace:*", "sonner": "^1.5.0", "sql-formatter": "^15.0.0", - "sqlstring": "^2.3.2", "streamdown": "^1.3.0", "@stripe/sync-engine": "1.0.32", "swiper": "^12.1.2", @@ -154,6 +163,8 @@ "@graphql-typed-document-node/core": "^3.2.0", "@radix-ui/react-use-escape-keydown": "^1.1.1", "@smithy/property-provider": "^4.0.4", + "@tailwindcss/vite": "4.2.4", + "@tanstack/devtools-vite": "^0.6.0", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", @@ -163,20 +174,16 @@ "@types/file-saver": "^2.0.2", "@types/json-logic-js": "^1.2.1", "@types/lodash": "^4.14.172", - "@types/markdown-table": "^3.0.0", "@types/mime-db": "^1.43.5", "@types/node": "catalog:", "@types/papaparse": "^5.3.1", "@types/randombytes": "^2.0.3", "@types/react": "catalog:", - "@types/react-beautiful-dnd": "^13.1.2", - "@types/react-datepicker": "^4.3.4", "@types/react-dom": "catalog:", "@types/react-grid-layout": "^1.3.0", "@types/react-simple-maps": "^3.0.1", - "@types/recharts": "^1.8.23", - "@types/sqlstring": "^2.3.0", "@types/zxcvbn": "^4.4.1", + "@vercel/config": "^0.2.1", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", "@vitest/ui": "catalog:", @@ -199,7 +206,6 @@ "postcss": "catalog:", "raw-loader": "^4.0.2", "require-in-the-middle": "^8.0.0", - "tailwindcss": "catalog:", "tsconfig": "workspace:*", "tsx": "catalog:", "typescript": "catalog:", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index f55fde22c31..2a4421b4a1d 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -1,5 +1,4 @@ import '@/styles/code.css' -import '@/styles/editor.css' import '@/styles/focus.css' import '@/styles/globals.css' import '@/styles/graphiql-base.css' @@ -39,7 +38,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { ErrorInfo, useCallback, useEffect, useState, type ComponentProps } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { TooltipProvider } from 'ui' -import { TimestampInfoProvider } from 'ui-patterns' +import { TimestampInfoProvider } from 'ui-patterns/TimestampInfo' import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu' import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider' @@ -53,7 +52,7 @@ import { GlobalErrorBoundaryState } from '@/components/ui/ErrorBoundary/GlobalEr import { GlobalShortcuts } from '@/components/ui/GlobalShortcuts/GlobalShortcuts' import { getCLIReleaseVersion } from '@/data/misc/cli-release-version-query' import { useRootQueryClient } from '@/data/query-client' -import { customFont, sourceCodePro } from '@/fonts' +import { inter, manrope, sourceCodePro } from '@/fonts' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { AuthProvider } from '@/lib/auth' @@ -119,18 +118,14 @@ const TimestampInfoTimezoneBridge = ({ children }: { children: React.ReactNode } return <TimestampInfoProvider timezone={timezone}>{children}</TimestampInfoProvider> } -loader.config({ - // [Joshen] Attempt for offline support/bypass ISP issues is to store the assets required for monaco - // locally. We're however, only storing the assets which we need (based on what the network tab loads - // while using monaco). If we end up facing more effort trying to maintain this, probably to either - // use cloudflare or find some way to pull all the files from a CDN via a CLI, rather than tracking individual files - // The alternative was to import * as monaco from 'monaco-editor' but i couldn't get it working - paths: { - vs: IS_PLATFORM - ? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' - : `${BASE_PATH}/monaco-editor`, - }, -}) +// [Ivan] Serve the Monaco assets locally from the public folder. The worker bootstrap +// (vs/base/worker/workerMain.js) loads the language workers (e.g. tsWorker.js) via fetch() +// from inside the web worker. A root-relative path fails to resolve there in some browsers +// (Firefox throws "... is not a valid URL"), so we point `vs` at an absolute URL including +// the origin. Guarded on `window` since this module is also evaluated during SSR. +if (typeof window !== 'undefined') { + loader.config({ paths: { vs: `${window.location.origin}${BASE_PATH}/monaco-editor/vs` } }) +} // [Joshen TODO] Once we settle on the new nav layout - we'll need a lot of clean up in terms of our layout components // a lot of them are unnecessary and introduce way too many cluttered CSS especially with the height styles that make @@ -191,7 +186,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { {/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */} <style dangerouslySetInnerHTML={{ - __html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`, + __html: `:root{--font-sans:${inter.style.fontFamily};--font-heading:${manrope.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`, }} /> {/* Speed up initial API loading times by pre-connecting to the API domain */} diff --git a/apps/studio/pages/_document.tsx b/apps/studio/pages/_document.tsx index 4ed23910d2d..8560136df0f 100644 --- a/apps/studio/pages/_document.tsx +++ b/apps/studio/pages/_document.tsx @@ -1,6 +1,6 @@ import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document' -import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' +import { inter, manrope, sourceCodePro } from '@/fonts' class MyDocument extends Document { static async getInitialProps(ctx: DocumentContext) { @@ -12,20 +12,8 @@ class MyDocument extends Document { render() { return ( <Html lang="en"> - <Head> - {/* Workaround for https://github.com/suren-atoyan/monaco-react/issues/272 */} - <link - rel="stylesheet" - type="text/css" - data-name="vs/editor/editor.main" - href={ - IS_PLATFORM - ? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/editor/editor.main.css' - : `${BASE_PATH}/monaco-editor/editor/editor.main.css` - } - /> - </Head> - <body> + <Head /> + <body className={`${inter.variable} ${manrope.variable} ${sourceCodePro.variable}`}> <Main /> <NextScript /> </body> diff --git a/apps/studio/pages/account/security.tsx b/apps/studio/pages/account/security.tsx index a6d8dfdb5cc..c52cf454728 100644 --- a/apps/studio/pages/account/security.tsx +++ b/apps/studio/pages/account/security.tsx @@ -1,5 +1,6 @@ import { Lock } from 'lucide-react' import { Badge, Card, CardContent, CardHeader } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, @@ -40,6 +41,15 @@ const Security: NextPageWithLayout = () => { </PageHeaderMeta> </PageHeader> <PageContainer size="small"> + {data?.totp.length === 1 && ( + <Admonition + className="mt-8" + type="danger" + layout="horizontal" + title="Avoid being locked out" + description="Add a backup sign-in method now. Otherwise, losing access to your authenticator app will permanently lock you out of your account." + /> + )} <Card className="mt-8"> <CardHeader className="py-3 flex flex-row items-center justify-between"> <div className="flex flex-row gap-4 items-center py-1 mb-0"> diff --git a/apps/studio/pages/api/ai/code/complete.ts b/apps/studio/pages/api/ai/code/complete.ts index 46ab01332db..1c2d8218662 100644 --- a/apps/studio/pages/api/ai/code/complete.ts +++ b/apps/studio/pages/api/ai/code/complete.ts @@ -9,8 +9,9 @@ import { executeSql } from '@/data/sql/execute-sql-mutation' import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { getOrgAIDetails } from '@/lib/ai/ai-details' import { getModel } from '@/lib/ai/model' -import { DEFAULT_COMPLETION_MODEL } from '@/lib/ai/model.utils' +import { DEFAULT_COMPLETION_MODEL, LOGS_REWRITE_MODEL } from '@/lib/ai/model.utils' import { + CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, COMPLETION_PROMPT, EDGE_FUNCTION_PROMPT, PG_BEST_PRACTICES, @@ -128,6 +129,7 @@ const requestBodySchema = z.object({ connectionString: z.string().nullish(), orgSlug: z.string().optional(), language: z.string().optional(), + dialect: z.enum(['postgres', 'clickhouse']).optional(), }) async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -148,8 +150,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues }) } - const { completionMetadata, projectRef, connectionString, orgSlug, language } = data + const { completionMetadata, projectRef, connectionString, orgSlug, language, dialect } = data const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata + const isClickhouse = dialect === 'clickhouse' const authorization = req.headers.authorization let aiOptInLevel: AiOptInLevel = IS_PLATFORM ? 'disabled' : 'schema' @@ -168,7 +171,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { systemProviderOptions, } = await getModel({ provider: 'openai', - modelEntry: DEFAULT_COMPLETION_MODEL, + modelEntry: isClickhouse ? LOGS_REWRITE_MODEL : DEFAULT_COMPLETION_MODEL, }) if (modelError) { @@ -180,7 +183,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...(authorization && { Authorization: authorization }), } - const includeSchema = aiOptInLevel !== 'disabled' + const includeSchema = !isClickhouse && aiOptInLevel !== 'disabled' // Fetch schema list first so we can determine which schemas to load DDL for. // These are best-effort — if they fail, we proceed without DDL context. @@ -227,29 +230,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { otherSchemas: schemas.filter((s) => !fetchedSchemaSet.has(s.name)).map((s) => s.name), } - // Important: do not use dynamic content in the system prompt or Bedrock will not cache it - const system = source` - ${COMPLETION_PROMPT} - ${language === 'sql' ? SQL_COMPLETION_INSTRUCTIONS : ''} - ${language === 'sql' ? PG_BEST_PRACTICES : EDGE_FUNCTION_PROMPT} - ${SECURITY_PROMPT} - ` + const system = isClickhouse + ? source` + You rewrite SQL queries to ClickHouse SQL for the Supabase logs table. + Output only the rewritten SQL query: no explanation, no markdown, and no code fences. + ${CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS} + ${SECURITY_PROMPT} + ` + : source` + ${COMPLETION_PROMPT} + ${language === 'sql' ? `${SQL_COMPLETION_INSTRUCTIONS}\n${PG_BEST_PRACTICES}` : EDGE_FUNCTION_PROMPT} + ${SECURITY_PROMPT} + ` - const userMessage = source` - ## Database Schema + const userMessage = isClickhouse + ? prompt + : source` + ## Database Schema - ${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })} + ${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })} - ## Code + ## Code - \`\`\`${language ?? ''} - ${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor} - \`\`\` + \`\`\`${language ?? ''} + ${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor} + \`\`\` - ## Instruction + ## Instruction - ${prompt} - ` + ${prompt} + ` // Note: these must be of type `CoreMessage` to prevent AI SDK from stripping `providerOptions` // https://github.com/vercel/ai/blob/81ef2511311e8af34d75e37fc8204a82e775e8c3/packages/ai/core/prompt/standardize-prompt.ts#L83-L88 diff --git a/apps/studio/pages/api/parse-query.ts b/apps/studio/pages/api/parse-query.ts index 4ca8ce1e5cb..b11f6190749 100644 --- a/apps/studio/pages/api/parse-query.ts +++ b/apps/studio/pages/api/parse-query.ts @@ -1,20 +1,14 @@ import { parse } from 'libpg-query' import { NextApiRequest, NextApiResponse } from 'next' -const getOperation = async (sql: string) => { - const ast = await parse(sql) - const stmt = ast.stmts?.[0]?.stmt - - if (!stmt) return null - +const getOperation = (stmt: Record<string, unknown>) => { if ('SelectStmt' in stmt) return 'SELECT' if ('InsertStmt' in stmt) return 'INSERT' if ('UpdateStmt' in stmt) return 'UPDATE' if ('DeleteStmt' in stmt) return 'DELETE' } -const getTablesInQuery = async (sql: string) => { - const ast = await parse(sql) +const getTablesInQuery = (ast: unknown) => { const tables: string[] = [] function traverse(node: unknown): void { @@ -40,6 +34,67 @@ const getTablesInQuery = async (sql: string) => { return [...new Set(tables)].sort((a, b) => a.localeCompare(b)) } +// libpg-query nodes only expose the location of their own leading token (e.g. a BoolExpr's +// location is its "AND"/"OR" operator, not the start of its left operand), and some nodes +// (SortBy) use -1 as an "unset" sentinel. Recursing to the smallest non-negative location in +// a subtree gives the true start of that subtree's text in the original SQL. +const getMinLocation = (node: unknown): number | undefined => { + if (!node || typeof node !== 'object') return undefined + const obj = node as Record<string, unknown> + let min: number | undefined + const consider = (loc: unknown) => { + if (typeof loc === 'number' && loc >= 0 && (min === undefined || loc < min)) min = loc + } + consider(obj.location) + for (const value of Object.values(obj)) { + if (Array.isArray(value)) value.forEach((item) => consider(getMinLocation(item))) + else if (value && typeof value === 'object') consider(getMinLocation(value)) + } + return min +} + +// Clauses that can immediately follow WHERE in each statement type, paired with the keyword +// that introduces them in the original SQL — used to find where the WHERE condition's text +// ends, since libpg-query has no deparser to do this for us. +const FOLLOWING_CLAUSES: Record<string, [field: string, keyword: RegExp][]> = { + SelectStmt: [ + ['groupClause', /\bgroup\s+by\b/i], + ['havingClause', /\bhaving\b/i], + ['windowClause', /\bwindow\b/i], + ['sortClause', /\border\s+by\b/i], + ['limitOffset', /\boffset\b/i], + ['limitCount', /\blimit\b/i], + ['lockingClause', /\bfor\b/i], + ], + UpdateStmt: [['returningList', /\breturning\b/i]], + DeleteStmt: [['returningList', /\breturning\b/i]], +} + +const getWhereClauseText = (sql: string, stmtType: string, stmt: Record<string, unknown>) => { + const start = getMinLocation(stmt.whereClause) + if (start === undefined) return null + + const candidateEnds: number[] = [] + for (const [field, keyword] of FOLLOWING_CLAUSES[stmtType] ?? []) { + const value = stmt[field] + const node = Array.isArray(value) ? value[0] : value + if (!node) continue + + // Bound the keyword search to before the clause's own expression when we know where that + // starts; otherwise (e.g. lockingClause, which carries no location at all) search onward. + const exprStart = getMinLocation(node) + const window = + exprStart !== undefined && exprStart > start ? sql.slice(start, exprStart) : sql.slice(start) + const match = keyword.exec(window) + if (match) candidateEnds.push(start + match.index) + else if (exprStart !== undefined && exprStart > start) candidateEnds.push(exprStart) + } + + const end = + candidateEnds.length > 0 ? Math.min(...candidateEnds) : sql.replace(/;\s*$/, '').length + return sql.slice(start, end).trim() +} + export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { res.setHeader('Allow', ['POST']) @@ -53,9 +108,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json({ error: 'Missing or invalid "sql" in request body' }) } - const tables = await getTablesInQuery(sql) - const operation = await getOperation(sql) - return res.status(200).json({ tables, operation }) + const ast = await parse(sql) + const statementCount = ast.stmts?.length ?? 0 + const stmt = ast.stmts?.[0]?.stmt as Record<string, unknown> | undefined + const [stmtType, stmtNode] = stmt ? Object.entries(stmt)[0] : [] + + const tables = getTablesInQuery(ast) + const operation = stmt ? getOperation(stmt) : null + const whereClause = + stmtType && stmtNode + ? getWhereClauseText(sql, stmtType, stmtNode as Record<string, unknown>) + : null + + return res.status(200).json({ tables, operation, whereClause, statementCount }) } catch (error) { const message = (error as { sqlDetails?: { message?: string } })?.sqlDetails?.message ?? diff --git a/apps/studio/pages/api/platform/projects/[ref]/config/postgrest.ts b/apps/studio/pages/api/platform/projects/[ref]/config/postgrest.ts index 2e494abe7ea..b8514526b91 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/config/postgrest.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/config/postgrest.ts @@ -2,6 +2,7 @@ import { components } from 'api-types' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' +import { DEFAULT_EXPOSED_SCHEMAS } from '@/lib/api/self-hosted/constants' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) @@ -21,7 +22,7 @@ const handleGet = async (_req: NextApiRequest, res: NextApiResponse) => { const responseObj: components['schemas']['GetPostgrestConfigResponse'] = { db_anon_role: 'anon', db_extra_search_path: process.env.PGRST_DB_EXTRA_SEARCH_PATH ?? 'public', - db_schema: process.env.PGRST_DB_SCHEMAS ?? 'public,storage,graphql_public', + db_schema: DEFAULT_EXPOSED_SCHEMAS, jwt_secret: process.env.AUTH_JWT_SECRET ?? 'super-secret-jwt-token-with-at-least-32-characters-long', max_rows: Number(process.env.PGRST_DB_MAX_ROWS) || 1000, diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/count.ts b/apps/studio/pages/api/platform/projects/[ref]/content/count.ts index 0edde385c5d..dc3c09f68a0 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/count.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/count.ts @@ -26,6 +26,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { try { const { snippets } = await getSnippets({ searchTerm: params?.name, + includeContent: false, }) if (params?.name) { return res.status(200).json({ diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts b/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts index ed8564c1111..ac599cef62b 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts @@ -30,6 +30,8 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse<GetRespons const folderId = (req.query.id as string) ?? null const folders = await getFolders(folderId) + // Folder listings return metadata only (no SQL body) to match the Management API contract; the + // editor loads each snippet's content on demand via the item endpoint. const { cursor, snippets } = await getSnippets({ searchTerm: params?.name, cursor: params?.cursor, @@ -37,6 +39,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse<GetRespons limit: params?.limit ? Number(params.limit) : undefined, sort: params?.sort_by, sortOrder: params?.sort_order, + includeContent: false, }) return res.status(200).json({ data: { folders: folders, contents: snippets }, cursor }) diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/folders/index.ts b/apps/studio/pages/api/platform/projects/[ref]/content/folders/index.ts index 1861df8c9f2..be8f426d3e0 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/folders/index.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/folders/index.ts @@ -31,12 +31,15 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse<GetRespons const params = req.query as GetRequestData const folders = await getFolders() + // Folder listings return metadata only (no SQL body) to match the Management API contract; the + // editor loads each snippet's content on demand via the item endpoint. const { cursor, snippets } = await getSnippets({ searchTerm: params?.name, limit: params?.limit ? Number(params.limit) : undefined, cursor: params?.cursor, sort: params?.sort_by, sortOrder: params?.sort_order, + includeContent: false, }) res.status(200).json({ data: { folders, contents: snippets }, cursor }) diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/index.ts b/apps/studio/pages/api/platform/projects/[ref]/content/index.ts index 53718b73d12..bf700e882f0 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/index.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/index.ts @@ -12,6 +12,17 @@ import { updateSnippet, } from '@/lib/api/snippets.utils' +// Next.js defaults the API body parser to a 1mb limit, which rejects large SQL +// snippets (e.g. multi-thousand-line RPCs) with a 413 before they can be saved. +// Match the 5mb limit already used by the AI SQL endpoint. +export const config = { + api: { + bodyParser: { + sizeLimit: '5mb', + }, + }, +} + const wrappedHandler = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) async function handler(req: NextApiRequest, res: NextApiResponse) { diff --git a/apps/studio/pages/cli/login.tsx b/apps/studio/pages/cli/login.tsx index 29132db3263..3ef79b48dd9 100644 --- a/apps/studio/pages/cli/login.tsx +++ b/apps/studio/pages/cli/login.tsx @@ -5,7 +5,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from 'react' import { Button, Card, CardContent } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { InterstitialAccountRow, diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 02a69b9ee89..1b55a32e4b9 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -117,6 +117,14 @@ const CreateProject = () => { const { data: organizationData } = useOrganizationsQuery() const organization = organizationData?.find((x) => x.slug === slug) + const hasTrackedFormExposed = useRef(false) + useEffect(() => { + if (hasTrackedFormExposed.current) return + if (!slug) return + hasTrackedFormExposed.current = true + track('project_creation_form_exposed', { surface: 'vercel' }, { organization: slug }) + }, [slug, track]) + /** * array of integrations installed */ diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 57ef2d799b3..f4d2b74a564 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -1,12 +1,12 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useFlag, useParams } from 'common' +import { useFeatureFlags, useFlag, useParams } from 'common' import Head from 'next/head' import Link from 'next/link' import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect, useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' -import { AWS_REGIONS, type CloudProvider } from 'shared-data' +import { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' +import { useForm, useFormState } from 'react-hook-form' +import { type CloudProvider } from 'shared-data' import { toast } from 'sonner' import { Button, Form, useWatch } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -37,7 +37,7 @@ import { GitHubRepositoryField, useGitHubRepositoryOptions, } from '@/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { WizardLayoutWithoutAuth } from '@/components/layouts/WizardLayout' import Panel from '@/components/ui/Panel' import { useAvailableOrioleImageVersion } from '@/data/config/project-creation-postgres-versions-query' @@ -71,13 +71,16 @@ import { usePHFlag } from '@/hooks/ui/useFlag' import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from '@/lib/constants' import { buildStudioPageTitle } from '@/lib/page-title' import { useProfile } from '@/lib/profile' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' import type { NextPageWithLayout } from '@/types' const sizesWithNoCostConfirmationRequired: DesiredInstanceSize[] = ['micro', 'small'] const Wizard: NextPageWithLayout = () => { const track = useTrack() + const trackFunnelError = useTrackFunnelError() const router = useRouter() const { slug, projectName } = useParams() const { appTitle } = useCustomContent(['app:title']) @@ -103,7 +106,7 @@ const Wizard: NextPageWithLayout = () => { 'integrations.github_connections' ) - const smartRegionEnabled = useFlag('enableSmartRegion') + const { hasLoaded: flagsLoaded } = useFeatureFlags() const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') const showInternalOnlyConfiguration = useFlag('newProjectInternalOnlyConfiguration') @@ -166,6 +169,9 @@ const Wizard: NextPageWithLayout = () => { projectName: watchedProjectName, highAvailability, } = useWatch({ control: form.control }) + const { dirtyFields } = useFormState(form) + const isDbRegionDirty = dirtyFields.dbRegion + const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' // Read dirty state during render rather than depending on form.formState in the // effect — form.formState is a Proxy that gets a new reference every render, which @@ -175,18 +181,6 @@ const Wizard: NextPageWithLayout = () => { form.formState ).isDirty - useEffect(() => { - if (dataApiRevokeOnCreateDefaultFlag === undefined) return - if (isDataApiDefaultPrivilegesDirty) return - setValue( - 'dataApiDefaultPrivileges', - !isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag), - { - shouldDirty: false, - } - ) - }, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue]) - // [Charis] Since the form is updated in a useEffect, there is an edge case // when switching from free to paid, where canChooseInstanceSize is true for // an in-between render, but watchedInstanceSize is still undefined from the @@ -227,12 +221,13 @@ const Wizard: NextPageWithLayout = () => { ? 0 : monthlyInstancePrice(instanceSize) - availableComputeCredits - const { data: _defaultRegion, error: defaultRegionError } = useDefaultRegionQuery( + const selectedCloudProvider = cloudProvider as CloudProvider + const { data: autoDefaultRegion, error: defaultRegionError } = useDefaultRegionQuery( { - cloudProvider: PROVIDERS[defaultProvider].id, + cloudProvider: selectedCloudProvider, }, { - enabled: !smartRegionEnabled, + enabled: flagsLoaded && !smartRegionEnabled, refetchOnMount: false, refetchOnWindowFocus: false, refetchInterval: false, @@ -249,27 +244,26 @@ const Wizard: NextPageWithLayout = () => { desiredInstanceSize: instanceSize as DesiredInstanceSize, }, { - enabled: smartRegionEnabled, + enabled: flagsLoaded && smartRegionEnabled, refetchOnMount: false, refetchOnWindowFocus: false, refetchInterval: false, refetchOnReconnect: false, } ) + const recommendedSmartRegion = smartRegionEnabled ? availableRegionsData?.recommendations.smartGroup.name : '' + const fixedDefaultRegion = PROVIDERS[selectedCloudProvider].default_region.displayName const regionError = smartRegionEnabled && defaultProvider !== 'AWS_NIMBUS' ? availableRegionsError : defaultRegionError - const defaultRegion = - defaultProvider === 'AWS_NIMBUS' - ? AWS_REGIONS.EAST_US.displayName - : smartRegionEnabled - ? availableRegionsData?.recommendations.smartGroup.name - : _defaultRegion + const defaultRegion = smartRegionEnabled + ? availableRegionsData?.recommendations.smartGroup.name + : (autoDefaultRegion ?? fixedDefaultRegion) const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices const canConfigureGitHubOnCreate = @@ -327,6 +321,10 @@ const Wizard: NextPageWithLayout = () => { ) router.push(`/project/${res.ref}`) }, + onError: (error) => { + toast.error(`Failed to create new project: ${error.message}`) + trackFunnelError('project_creation', classifyApiError('project_creation', error), 'toast') + }, }) const onSubmitWithComputeCostsConfirmation = async (values: z.infer<typeof FormSchema>) => { @@ -366,6 +364,11 @@ const Wizard: NextPageWithLayout = () => { } = values if (useOrioleDb && !availableOrioleVersion) { + trackFunnelError( + 'project_creation', + { errorCategory: 'validation', errorReason: 'oriole_unavailable' }, + 'toast' + ) return toast.error('No available OrioleDB image found, only Postgres is available') } @@ -426,6 +429,14 @@ const Wizard: NextPageWithLayout = () => { createProject(data) } + const hasTrackedFormExposed = useRef(false) + useEffect(() => { + if (hasTrackedFormExposed.current) return + if (!isOrganizationsSuccess || !canCreateProject || !currentOrg) return + hasTrackedFormExposed.current = true + track('project_creation_form_exposed', { surface: 'main' }) + }, [isOrganizationsSuccess, canCreateProject, currentOrg, track]) + useEffect(() => { // Only set once to ensure compute credits dont change while project is being created if (allOrgProjects && allOrgProjects.length > 0 && !allProjects) { @@ -447,27 +458,24 @@ const Wizard: NextPageWithLayout = () => { if (projectName) setValue('projectName', projectName || '') }, [slug, setValue, projectName]) - const isDbRegionDirty = getFieldState('dbRegion', form.formState).isDirty useEffect(() => { if (!isDbRegionDirty && defaultRegion) { setValue('dbRegion', defaultRegion) } }, [defaultRegion, isDbRegionDirty, setValue]) - useEffect(() => { - if (regionError) { - resetField('dbRegion', { - defaultValue: PROVIDERS[defaultProvider].default_region.displayName, - }) - } - }, [regionError, resetField, defaultProvider]) - useEffect(() => { if (!isDbRegionDirty && recommendedSmartRegion) { setValue('dbRegion', recommendedSmartRegion) } }, [recommendedSmartRegion, isDbRegionDirty, setValue]) + useEffect(() => { + if (regionError && fixedDefaultRegion) { + resetField('dbRegion', { defaultValue: fixedDefaultRegion }) + } + }, [regionError, resetField, fixedDefaultRegion]) + useEffect(() => { if (highAvailability && cloudProvider !== 'AWS_K8S') { setValue('cloudProvider', 'AWS_K8S') @@ -494,6 +502,18 @@ const Wizard: NextPageWithLayout = () => { }) }, [githubRepositoryName, watchedProjectName, setValue]) + useEffect(() => { + if (dataApiRevokeOnCreateDefaultFlag === undefined) return + if (isDataApiDefaultPrivilegesDirty) return + setValue( + 'dataApiDefaultPrivileges', + !isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag), + { + shouldDirty: false, + } + ) + }, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue]) + return ( <> {/* Wizard layouts set the visual header but not the browser tab title. */} @@ -502,7 +522,15 @@ const Wizard: NextPageWithLayout = () => { <meta name="description" content="Supabase Studio" /> </Head> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmitWithComputeCostsConfirmation)}> + <form + onSubmit={form.handleSubmit(onSubmitWithComputeCostsConfirmation, (errors) => + trackFunnelError( + 'project_creation', + classifyValidationError('project_creation', errors), + 'form' + ) + )} + > <Panel loading={!isOrganizationsSuccess} title={ diff --git a/apps/studio/pages/org/[slug]/documents.tsx b/apps/studio/pages/org/[slug]/documents.tsx index 7cee99b18c6..fa4325b4d61 100644 --- a/apps/studio/pages/org/[slug]/documents.tsx +++ b/apps/studio/pages/org/[slug]/documents.tsx @@ -5,7 +5,7 @@ import { PageHeaderMeta, PageHeaderSummary, PageHeaderTitle, -} from 'ui-patterns' +} from 'ui-patterns/PageHeader' import { Documents } from '@/components/interfaces/Organization/Documents/Documents' import { DefaultLayout } from '@/components/layouts/DefaultLayout' diff --git a/apps/studio/pages/org/[slug]/general.tsx b/apps/studio/pages/org/[slug]/general.tsx index a5170df5af1..4eec2e6ab4f 100644 --- a/apps/studio/pages/org/[slug]/general.tsx +++ b/apps/studio/pages/org/[slug]/general.tsx @@ -9,7 +9,7 @@ import { } from 'ui-patterns/PageHeader' import { GeneralSettings } from '@/components/interfaces/Organization/GeneralSettings/GeneralSettings' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import OrganizationLayout from '@/components/layouts/OrganizationLayout' import { OrganizationSettingsLayout } from '@/components/layouts/ProjectLayout/OrganizationSettingsLayout' import { usePermissionsQuery } from '@/data/permissions/permissions-query' diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index 57bb03caaa7..409d7327658 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -1,12 +1,12 @@ import { useIsMFAEnabled } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ProjectList } from '@/components/interfaces/Home/ProjectList/ProjectList' import { HomePageActions } from '@/components/interfaces/HomePageActions' import { PlanUsageCard } from '@/components/interfaces/ProjectHome/PlanUsageCard' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import OrganizationLayout from '@/components/layouts/OrganizationLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' diff --git a/apps/studio/pages/org/[slug]/integrations.tsx b/apps/studio/pages/org/[slug]/integrations.tsx index 0aa36a51f55..9fdce80d0b3 100644 --- a/apps/studio/pages/org/[slug]/integrations.tsx +++ b/apps/studio/pages/org/[slug]/integrations.tsx @@ -8,7 +8,7 @@ import { } from 'ui-patterns/PageHeader' import { IntegrationSettings } from '@/components/interfaces/Organization/IntegrationSettings/IntegrationSettings' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import OrganizationLayout from '@/components/layouts/OrganizationLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/org/[slug]/security.tsx b/apps/studio/pages/org/[slug]/security.tsx index aa6f36dc5e3..261e98b69d9 100644 --- a/apps/studio/pages/org/[slug]/security.tsx +++ b/apps/studio/pages/org/[slug]/security.tsx @@ -8,7 +8,7 @@ import { } from 'ui-patterns/PageHeader' import { SecuritySettings } from '@/components/interfaces/Organization/SecuritySettings' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import OrganizationLayout from '@/components/layouts/OrganizationLayout' import { OrganizationSettingsLayout } from '@/components/layouts/ProjectLayout/OrganizationSettingsLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' diff --git a/apps/studio/pages/org/[slug]/team.tsx b/apps/studio/pages/org/[slug]/team.tsx index 7b4372fb74b..0ddb6d7b49b 100644 --- a/apps/studio/pages/org/[slug]/team.tsx +++ b/apps/studio/pages/org/[slug]/team.tsx @@ -1,7 +1,7 @@ import { LogoLoader } from 'ui' import { TeamSettings } from '@/components/interfaces/Organization/TeamSettings/TeamSettings' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import OrganizationLayout from '@/components/layouts/OrganizationLayout' import { usePermissionsQuery } from '@/data/permissions/permissions-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' diff --git a/apps/studio/pages/org/[slug]/usage.tsx b/apps/studio/pages/org/[slug]/usage.tsx index 1276d6cb7c3..44698cb23ff 100644 --- a/apps/studio/pages/org/[slug]/usage.tsx +++ b/apps/studio/pages/org/[slug]/usage.tsx @@ -1,5 +1,5 @@ import { Usage } from '@/components/interfaces/Organization/Usage/Usage' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import OrganizationLayout from '@/components/layouts/OrganizationLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/org/_/[[...routeSlug]].tsx b/apps/studio/pages/org/_/[[...routeSlug]].tsx index b910436b218..f40cf881fbd 100644 --- a/apps/studio/pages/org/_/[[...routeSlug]].tsx +++ b/apps/studio/pages/org/_/[[...routeSlug]].tsx @@ -3,6 +3,7 @@ import Head from 'next/head' import { useRouter } from 'next/router' import { cn } from 'ui' +import { parseCatchAllRoute } from '@/compat/next/router' import { Header, LoadingCardView, @@ -20,7 +21,10 @@ import { buildStudioPageTitle } from '@/lib/page-title' const GenericOrganizationPage: NextPage = () => { const router = useRouter() const { appTitle } = useCustomContent(['app:title']) - const { routeSlug, ...queryParams } = router.query + // Normalise the catch-all path across Next (`routeSlug: string[]`) and + // TanStack (`_splat: string`) so downstream URL building (buildOrgUrl) + // keeps working — see parseCatchAllRoute. + const { segments: routeSlug, queryParams } = parseCatchAllRoute(router.query, 'routeSlug') const queryString = Object.keys(queryParams).length > 0 ? new URLSearchParams(queryParams as Record<string, string>).toString() diff --git a/apps/studio/pages/organizations.tsx b/apps/studio/pages/organizations.tsx index 98fb524116e..8ea61cabe20 100644 --- a/apps/studio/pages/organizations.tsx +++ b/apps/studio/pages/organizations.tsx @@ -1,11 +1,9 @@ -import { useParams } from 'common' import { Plus, Search } from 'lucide-react' import Head from 'next/head' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { Button, Skeleton } from 'ui' -import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { NoOrganizationsState } from '@/components/interfaces/Home/ProjectList/EmptyStates' @@ -27,8 +25,6 @@ const OrganizationsPage: NextPageWithLayout = () => { const router = useRouter() const { appTitle } = useCustomContent(['app:title']) const [search, setSearch] = useState('') - const { error: orgNotFoundError, org: orgSlug } = useParams() - const orgNotFound = orgNotFoundError === 'org_not_found' const pageTitle = buildStudioPageTitle({ section: 'Organizations', brand: appTitle || 'Supabase', @@ -52,8 +48,7 @@ const OrganizationsPage: NextPageWithLayout = () => { useEffect(() => { // If there are no organizations, force the user to create one - // unless the user is on the not found page - if (isSuccess && organizations.length <= 0 && !orgNotFound) { + if (isSuccess && organizations.length <= 0) { router.push('/new') } }, [isSuccess, organizations]) @@ -66,20 +61,6 @@ const OrganizationsPage: NextPageWithLayout = () => { </Head> <ScaffoldContainer> <ScaffoldSection isFullWidth className="flex flex-col gap-y-4"> - {orgNotFound && ( - <Admonition - type="destructive" - title="Organization not found" - description={ - <> - The organization <code className="text-code-inline">{orgSlug}</code> does not - exist or you do not have permission to access to it. Contact the the owner if you - believe this is a mistake. - </> - } - /> - )} - {organizations.length > 0 && ( <div className="flex items-center justify-between gap-x-2 md:gap-x-3"> <Input diff --git a/apps/studio/pages/partners/stripe/projects/login.tsx b/apps/studio/pages/partners/stripe/projects/login.tsx index b47974f4e79..bb1afc2a829 100644 --- a/apps/studio/pages/partners/stripe/projects/login.tsx +++ b/apps/studio/pages/partners/stripe/projects/login.tsx @@ -5,7 +5,8 @@ import Head from 'next/head' import { useRouter } from 'next/router' import { useEffect } from 'react' import { Button } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { InterstitialAccountRow, diff --git a/apps/studio/pages/project/[ref]/auth/audit-logs.tsx b/apps/studio/pages/project/[ref]/auth/audit-logs.tsx index 5c953f5384d..723c8047e81 100644 --- a/apps/studio/pages/project/[ref]/auth/audit-logs.tsx +++ b/apps/studio/pages/project/[ref]/auth/audit-logs.tsx @@ -13,9 +13,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { AuditLogsForm } from '@/components/interfaces/Auth/AuditLogsForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/hooks.tsx b/apps/studio/pages/project/[ref]/auth/hooks.tsx index e86b3668d63..c941e734e0d 100644 --- a/apps/studio/pages/project/[ref]/auth/hooks.tsx +++ b/apps/studio/pages/project/[ref]/auth/hooks.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { GenericSkeletonLoader } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, @@ -10,12 +9,13 @@ import { PageHeaderTitle, } from 'ui-patterns/PageHeader' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { HooksListing } from '@/components/interfaces/Auth/Hooks/HooksListing' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/mfa.tsx b/apps/studio/pages/project/[ref]/auth/mfa.tsx index 93e6ca14057..4cd007eaf79 100644 --- a/apps/studio/pages/project/[ref]/auth/mfa.tsx +++ b/apps/studio/pages/project/[ref]/auth/mfa.tsx @@ -13,8 +13,8 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { MfaAuthSettingsForm } from '@/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx index 30bd8430415..9b44e7ae864 100644 --- a/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx +++ b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx @@ -1,4 +1,3 @@ -import { PageSection, PageSectionContent } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, @@ -7,10 +6,11 @@ import { PageHeaderSummary, PageHeaderTitle, } from 'ui-patterns/PageHeader' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { OAuthAppsList } from '@/components/interfaces/Auth/OAuthApps/OAuthAppsList' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/oauth-server.tsx b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx index 73f7c6ed452..ad3350c2567 100644 --- a/apps/studio/pages/project/[ref]/auth/oauth-server.tsx +++ b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx @@ -10,7 +10,7 @@ import { import { OAuthServerSettingsForm } from '@/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/overview.tsx b/apps/studio/pages/project/[ref]/auth/overview.tsx index 3216c32d1e6..c1471399345 100644 --- a/apps/studio/pages/project/[ref]/auth/overview.tsx +++ b/apps/studio/pages/project/[ref]/auth/overview.tsx @@ -13,7 +13,7 @@ import { import { OverviewLearnMore } from '@/components/interfaces/Auth/Overview/OverviewLearnMore' import { OverviewMetrics } from '@/components/interfaces/Auth/Overview/OverviewMetrics' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { useAuthOverviewQuery } from '@/data/auth/auth-overview-query' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/pages/project/[ref]/auth/passkeys.tsx b/apps/studio/pages/project/[ref]/auth/passkeys.tsx index 4d1ae271667..6a03b867e0f 100644 --- a/apps/studio/pages/project/[ref]/auth/passkeys.tsx +++ b/apps/studio/pages/project/[ref]/auth/passkeys.tsx @@ -12,7 +12,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { PasskeysSettingsForm } from '@/components/interfaces/Auth/Passkeys/PasskeysSettingsForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import type { NextPageWithLayout } from '@/types' const PasskeysPage: NextPageWithLayout = () => { diff --git a/apps/studio/pages/project/[ref]/auth/performance.tsx b/apps/studio/pages/project/[ref]/auth/performance.tsx index 01d69fb7f81..ffd935d7c03 100644 --- a/apps/studio/pages/project/[ref]/auth/performance.tsx +++ b/apps/studio/pages/project/[ref]/auth/performance.tsx @@ -13,8 +13,8 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { PerformanceSettingsForm } from '@/components/interfaces/Auth/PerformanceSettingsForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/auth/protection.tsx b/apps/studio/pages/project/[ref]/auth/protection.tsx index fc0924248e1..02cdfd2ce47 100644 --- a/apps/studio/pages/project/[ref]/auth/protection.tsx +++ b/apps/studio/pages/project/[ref]/auth/protection.tsx @@ -13,8 +13,8 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ProtectionAuthSettingsForm } from '@/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/auth/providers.tsx b/apps/studio/pages/project/[ref]/auth/providers.tsx index d0dcea5bbd6..70d51d19b4d 100644 --- a/apps/studio/pages/project/[ref]/auth/providers.tsx +++ b/apps/studio/pages/project/[ref]/auth/providers.tsx @@ -1,24 +1,22 @@ -import { useFlag } from 'common' import { PageContainer } from 'ui-patterns/PageContainer' import { AuthProvidersForm } from '@/components/interfaces/Auth/AuthProvidersForm' import { BasicAuthSettingsForm } from '@/components/interfaces/Auth/BasicAuthSettingsForm' import { CustomAuthProviders } from '@/components/interfaces/Auth/CustomAuthProviders' import { AuthProvidersLayout } from '@/components/layouts/AuthLayout/AuthProvidersLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from '@/types' const ProvidersPage: NextPageWithLayout = () => { const showProviders = useIsFeatureEnabled('authentication:show_providers') const showCustomProviders = useIsFeatureEnabled('authentication:show_custom_providers') - const isOauthProvidersEnabled = useFlag('CustomOauthProviders') return ( <PageContainer size="default"> <BasicAuthSettingsForm /> {showProviders && <AuthProvidersForm />} - {showCustomProviders && isOauthProvidersEnabled && <CustomAuthProviders />} + {showCustomProviders && <CustomAuthProviders />} </PageContainer> ) } diff --git a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx index dcdac29b599..f09a72daccb 100644 --- a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx +++ b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx @@ -14,9 +14,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { RateLimits } from '@/components/interfaces/Auth/RateLimits/RateLimits' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/auth/sessions.tsx b/apps/studio/pages/project/[ref]/auth/sessions.tsx index 9b4f96b3e65..1ef04b4557f 100644 --- a/apps/studio/pages/project/[ref]/auth/sessions.tsx +++ b/apps/studio/pages/project/[ref]/auth/sessions.tsx @@ -12,8 +12,8 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { SessionsAuthSettingsForm } from '@/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/smtp.tsx b/apps/studio/pages/project/[ref]/auth/smtp.tsx index 1c512bebeb7..e6856e5a38e 100644 --- a/apps/studio/pages/project/[ref]/auth/smtp.tsx +++ b/apps/studio/pages/project/[ref]/auth/smtp.tsx @@ -1,12 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { GenericSkeletonLoader } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { SmtpForm } from '@/components/interfaces/Auth/SmtpForm/SmtpForm' import { AuthEmailsLayout } from '@/components/layouts/AuthLayout/AuthEmailsLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx index 01346476643..93a4c95abb0 100644 --- a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx +++ b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx @@ -21,7 +21,7 @@ import { FormField, Switch, } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -40,21 +40,22 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { TEMPLATES_SCHEMAS } from '@/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation' import { CustomEmailTemplateRestrictionAdmonition } from '@/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition' -import { EMAIL_TEMPLATE_DOCS_ANCHORS } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants' +import { AUTH_EMAIL_TEMPLATES_DOCS_PATH } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.constants' import { isCustomEmailTemplateEditingRestricted, isCustomEmailTemplateRestrictionStatusKnown, slugifyTitle, } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils' +import { SendEmailHookActiveAdmonition } from '@/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition' import { TemplateEditor } from '@/components/interfaces/Auth/EmailTemplates/TemplateEditor' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' -import { InlineLink } from '@/components/ui/InlineLink' import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' @@ -99,6 +100,9 @@ const RedirectToTemplates = () => { projectInsertedAt: selectedProject?.inserted_at, }) const isTemplateEditorReadOnly = !isTemplateRestrictionStatusKnown || isTemplateEditBlocked + const hasSendEmailHook = !!( + authConfig?.HOOK_SEND_EMAIL_ENABLED && authConfig?.HOOK_SEND_EMAIL_URI + ) const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation({ onError: (error) => { @@ -208,13 +212,8 @@ const RedirectToTemplates = () => { {template.purpose || 'Configure and customize email templates.'} </PageHeaderDescription> </PageHeaderSummary> - <PageHeaderAside className="flex items-center gap-3"> - <InlineLink href={`${DOCS_URL}/guides/auth/auth-email-templates#terminology`}> - Terminology - </InlineLink> - <DocsButton - href={`${DOCS_URL}/guides/local-development/customizing-email-templates#${EMAIL_TEMPLATE_DOCS_ANCHORS[template.id]}`} - /> + <PageHeaderAside> + <DocsButton href={`${DOCS_URL}${AUTH_EMAIL_TEMPLATES_DOCS_PATH}`} /> </PageHeaderAside> </PageHeaderMeta> </PageHeader> @@ -286,7 +285,7 @@ const RedirectToTemplates = () => { )} <PageSection> - {showConfigurationSection && ( + {(showConfigurationSection || isTemplateEditBlocked) && ( <PageSectionMeta> <PageSectionSummary> <PageSectionTitle>Content</PageSectionTitle> @@ -294,11 +293,16 @@ const RedirectToTemplates = () => { </PageSectionMeta> )} <PageSectionContent> - {isTemplateEditBlocked && ( + {isTemplateEditBlocked && !hasSendEmailHook && ( <div className="mb-4"> <CustomEmailTemplateRestrictionAdmonition /> </div> )} + {hasSendEmailHook && ( + <div className="mb-4"> + <SendEmailHookActiveAdmonition /> + </div> + )} <Card> <TemplateEditor template={template} isReadOnly={isTemplateEditorReadOnly} /> </Card> diff --git a/apps/studio/pages/project/[ref]/auth/templates/index.tsx b/apps/studio/pages/project/[ref]/auth/templates/index.tsx index 5bb1b874d1f..f02769b959f 100644 --- a/apps/studio/pages/project/[ref]/auth/templates/index.tsx +++ b/apps/studio/pages/project/[ref]/auth/templates/index.tsx @@ -1,12 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { GenericSkeletonLoader } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { EmailTemplates } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates' import { AuthEmailsLayout } from '@/components/layouts/AuthLayout/AuthEmailsLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/third-party.tsx b/apps/studio/pages/project/[ref]/auth/third-party.tsx index 216f852dba0..188bdd3abd4 100644 --- a/apps/studio/pages/project/[ref]/auth/third-party.tsx +++ b/apps/studio/pages/project/[ref]/auth/third-party.tsx @@ -5,8 +5,8 @@ import { PageContainer } from 'ui-patterns/PageContainer' import { ThirdPartyAuthForm } from '@/components/interfaces/Auth/ThirdPartyAuthForm' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' import { AuthProvidersLayout } from '@/components/layouts/AuthLayout/AuthProvidersLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx index 414ea18c80b..81f42779c0c 100644 --- a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx +++ b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx @@ -13,8 +13,8 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { RedirectUrls } from '@/components/interfaces/Auth/RedirectUrls/RedirectUrls' import SiteUrl from '@/components/interfaces/Auth/SiteUrl/SiteUrl' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import NoPermission from '@/components/ui/NoPermission' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/users.tsx b/apps/studio/pages/project/[ref]/auth/users.tsx index 8f40389363e..be7691ad503 100644 --- a/apps/studio/pages/project/[ref]/auth/users.tsx +++ b/apps/studio/pages/project/[ref]/auth/users.tsx @@ -1,6 +1,6 @@ import { UsersV2 } from '@/components/interfaces/Auth/Users/UsersV2' import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import type { NextPageWithLayout } from '@/types' const UsersPage: NextPageWithLayout = () => { diff --git a/apps/studio/pages/project/[ref]/branches/index.tsx b/apps/studio/pages/project/[ref]/branches/index.tsx index ac37bdaede7..102a0783c76 100644 --- a/apps/studio/pages/project/[ref]/branches/index.tsx +++ b/apps/studio/pages/project/[ref]/branches/index.tsx @@ -3,7 +3,7 @@ import { useParams } from 'common' import { partition } from 'lodash' import { MessageCircle } from 'lucide-react' import { useRouter } from 'next/router' -import { useState } from 'react' +import { useState, type PropsWithChildren } from 'react' import { toast } from 'sonner' import { Button } from 'ui' @@ -189,73 +189,74 @@ const BranchesPage: NextPageWithLayout = () => { ) } -BranchesPage.getLayout = (page) => { - const BranchesPageWrapper = () => { - const snap = useAppStateSnapshot() - const { can: canCreateBranches } = useAsyncCheckPermissions( - PermissionAction.CREATE, - 'preview_branches', - { - resource: { is_default: false }, - } - ) +// Hoisted out of `getLayout` so the TanStack route can import it +// directly. Same shape and identical body as before — accepts the page +// content as `children` instead of capturing it from a closure. +export const BranchesPageWrapper = ({ children }: PropsWithChildren) => { + const snap = useAppStateSnapshot() + const { can: canCreateBranches } = useAsyncCheckPermissions( + PermissionAction.CREATE, + 'preview_branches', + { + resource: { is_default: false }, + } + ) - const primaryActions = ( - <ButtonTooltip - variant="primary" - disabled={!canCreateBranches} - onClick={() => snap.setShowCreateBranchModal(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateBranches - ? 'You need additional permissions to create branches' - : undefined, - }, - }} + const primaryActions = ( + <ButtonTooltip + variant="primary" + disabled={!canCreateBranches} + onClick={() => snap.setShowCreateBranchModal(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateBranches + ? 'You need additional permissions to create branches' + : undefined, + }, + }} + > + Create branch + </ButtonTooltip> + ) + + const secondaryActions = ( + <div className="flex items-center gap-x-2"> + <Button + asChild + variant="text" + icon={<MessageCircle className="text-muted" strokeWidth={1} />} > - Create branch - </ButtonTooltip> - ) - - const secondaryActions = ( - <div className="flex items-center gap-x-2"> - <Button - asChild - variant="text" - icon={<MessageCircle className="text-muted" strokeWidth={1} />} + <a + target="_blank" + rel="noreferrer" + href="https://github.com/orgs/supabase/discussions/18937" > - <a - target="_blank" - rel="noreferrer" - href="https://github.com/orgs/supabase/discussions/18937" - > - Branching feedback - </a> - </Button> - <DocsButton href={`${DOCS_URL}/guides/platform/branching`} /> - </div> - ) - - return ( - <PageLayout - title="Branches" - subtitle="Manage your database preview branches and deployments" - primaryActions={primaryActions} - secondaryActions={secondaryActions} - > - {page} - </PageLayout> - ) - } + Branching feedback + </a> + </Button> + <DocsButton href={`${DOCS_URL}/guides/platform/branching`} /> + </div> + ) return ( - <DefaultLayout> - <BranchLayout> - <BranchesPageWrapper /> - </BranchLayout> - </DefaultLayout> + <PageLayout + title="Branches" + subtitle="Manage your database preview branches and deployments" + primaryActions={primaryActions} + secondaryActions={secondaryActions} + > + {children} + </PageLayout> ) } +BranchesPage.getLayout = (page) => ( + <DefaultLayout> + <BranchLayout> + <BranchesPageWrapper>{page}</BranchesPageWrapper> + </BranchLayout> + </DefaultLayout> +) + export default BranchesPage diff --git a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx index 3f3d163aea7..10aeeec6669 100644 --- a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx +++ b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx @@ -22,12 +22,12 @@ import { import { BranchSelector } from '@/components/interfaces/BranchManagement/BranchSelector' import { PullRequestsEmptyState } from '@/components/interfaces/BranchManagement/EmptyStates' import BranchLayout from '@/components/layouts/BranchLayout/BranchLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation' import { Branch, useBranchesQuery } from '@/data/branches/branches-query' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' @@ -300,7 +300,7 @@ const MergeRequestsPage: NextPageWithLayout = () => { ) } -const MergeRequestsPageWrapper = ({ children }: PropsWithChildren<{}>) => { +export const MergeRequestsPageWrapper = ({ children }: PropsWithChildren<{}>) => { const router = useRouter() const { ref } = useParams() const { data: project } = useSelectedProjectQuery() diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index f65f79a470b..1c7d72cbf30 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -1,8 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { AlertCircle } from 'lucide-react' +import { AlertCircle, DatabaseBackup } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, @@ -18,14 +18,16 @@ import DatabaseBackupsNav from '@/components/interfaces/Database/Backups/Databas import { PITRNotice } from '@/components/interfaces/Database/Backups/PITR/PITRNotice' import { PITRSelection } from '@/components/interfaces/Database/Backups/PITR/PITRSelection' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import AlertError from '@/components/ui/AlertError' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useBackupsQuery } from '@/data/database/backups-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useIsOrioleDbInAws, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL, PROJECT_STATUS } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' @@ -64,7 +66,8 @@ DatabasePhysicalBackups.getLayout = (page) => ( const PITR = () => { const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() + const { data: project, isPending: isProjectPending } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const { hasAccess: hasAccessToPitr, isLoading: isLoadingEntitlements } = useCheckEntitlements('pitr.available_variants') const isOrioleDbInAws = useIsOrioleDbInAws() @@ -76,7 +79,7 @@ const PITR = () => { isSuccess, } = useBackupsQuery({ projectRef }) - const isLoading = isLoadingBackups || isLoadingEntitlements + const isLoading = isLoadingBackups || isLoadingEntitlements || isProjectPending const isEnabled = backups?.pitr_enabled const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY @@ -101,21 +104,23 @@ const PITR = () => { ) } - if (project?.high_availability) { + if (isLoading) { + return <GenericSkeletonLoader /> + } + + if (isHighAvailability) { return ( - <Admonition - type="default" - title="Database backups are not available for High Availability projects" - description="High Availability projects do not support Point-in-Time Recovery (PITR) backups" - > - <DocsButton abbrev={false} className="mt-2" href={DOCS_URL} /> - </Admonition> + <HighAvailabilityDisabledEmptyState + icon={DatabaseBackup} + title="Point-in-Time Recovery unavailable on High Availability projects" + description="We're working to bring point-in-time recovery to High Availability projects. Contact support if this is blocking your work." + className="max-w-none mx-0" + /> ) } return ( <> - {isLoading && <GenericSkeletonLoader />} {isError && <AlertError error={error} subject="Failed to retrieve PITR backups" />} {isSuccess && ( <> @@ -124,7 +129,11 @@ const PITR = () => { addon={hasAccessToPitr ? 'pitr' : undefined} source="pitr" featureProposition="enable Point-in-Time Recovery" - primaryText="Point in Time Recovery is a Pro Plan add-on" + primaryText={ + hasAccessToPitr + ? 'Point in Time Recovery is available as an add-on' + : 'Point in Time Recovery is a Pro Plan add-on' + } secondaryText={ !hasAccessToPitr ? 'Roll back your database to a specific second. Starts at $100/month. Pro Plan already includes daily backups at no extra cost.' diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx index ed6bfbf717b..8745655e4ec 100644 --- a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx @@ -12,7 +12,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import DatabaseBackupsNav from '@/components/interfaces/Database/Backups/DatabaseBackupsNav' import { RestoreToNewProject } from '@/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index e04b620bdc1..dd77bf41bda 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { Info } from 'lucide-react' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, @@ -16,11 +16,11 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { BackupsList } from '@/components/interfaces/Database/Backups/BackupsList' import DatabaseBackupsNav from '@/components/interfaces/Database/Backups/DatabaseBackupsNav' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' -import AlertError from '@/components/ui/AlertError' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import InformationBox from '@/components/ui/InformationBox' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useBackupsQuery } from '@/data/database/backups-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsOrioleDbInAws } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index d882afb19e1..841c79d275a 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -20,9 +20,9 @@ import PrivilegesHead from '@/components/interfaces/Database/Privileges/Privileg import PrivilegesTable from '@/components/interfaces/Database/Privileges/PrivilegesTable' import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' import { PgRole, useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' diff --git a/apps/studio/pages/project/[ref]/database/extensions.tsx b/apps/studio/pages/project/[ref]/database/extensions.tsx index 5c56361d6cf..0d20aa7facc 100644 --- a/apps/studio/pages/project/[ref]/database/extensions.tsx +++ b/apps/studio/pages/project/[ref]/database/extensions.tsx @@ -12,9 +12,9 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { Extensions } from '@/components/interfaces/Database/Extensions/Extensions' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index 3c2f32bd082..d4945b30693 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -13,7 +13,7 @@ import { FunctionsList } from '@/components/interfaces/Database/Functions/Functi import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/database/migrations.tsx b/apps/studio/pages/project/[ref]/database/migrations.tsx index df018c9c8c2..e57b15dd6fd 100644 --- a/apps/studio/pages/project/[ref]/database/migrations.tsx +++ b/apps/studio/pages/project/[ref]/database/migrations.tsx @@ -11,7 +11,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import Migrations from '@/components/interfaces/Database/Migrations/Migrations' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/database/policies.tsx similarity index 94% rename from apps/studio/pages/project/[ref]/auth/policies.tsx rename to apps/studio/pages/project/[ref]/database/policies.tsx index 43efa9f4c6c..654a48de3fb 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/database/policies.tsx @@ -22,16 +22,16 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' import { useIsRLSTesterEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { Policies } from '@/components/interfaces/Auth/Policies/Policies' -import { PoliciesDataProvider } from '@/components/interfaces/Auth/Policies/PoliciesDataContext' -import { getGeneralPolicyTemplates } from '@/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants' -import { PolicyEditorPanel } from '@/components/interfaces/Auth/Policies/PolicyEditorPanel' +import { Policies } from '@/components/interfaces/Database/Policies/Policies' +import { getGeneralPolicyTemplates } from '@/components/interfaces/Database/Policies/Policies.constants' +import { PoliciesDataProvider } from '@/components/interfaces/Database/Policies/PoliciesDataContext' +import { PolicyEditorPanel } from '@/components/interfaces/Database/Policies/PolicyEditorPanel' import { generatePolicyUpdateSQL, type Policy, -} from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import { RLSTesterSheet } from '@/components/interfaces/Auth/RLSTester/RLSTesterSheet' -import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' +} from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' +import { RLSTesterSheet } from '@/components/interfaces/Database/RLSTester/RLSTesterSheet' +import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { getExposedSchemas } from '@/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils' @@ -97,7 +97,7 @@ const getTableFilterState = (tables: PGTable[], policies: Array<Policy>, searchS return { tables: sortedTables, visibleTableIds } } -const AuthPoliciesPage: NextPageWithLayout = () => { +const DatabasePoliciesPage: NextPageWithLayout = () => { const [schema, setSchema] = useQueryState( 'schema', parseAsString.withDefault('public').withOptions({ history: 'replace' }) @@ -401,10 +401,10 @@ const AuthPoliciesPage: NextPageWithLayout = () => { ) } -AuthPoliciesPage.getLayout = (page) => ( +DatabasePoliciesPage.getLayout = (page) => ( <DefaultLayout> - <AuthLayout title="Policies">{page}</AuthLayout> + <DatabaseLayout title="Policies">{page}</DatabaseLayout> </DefaultLayout> ) -export default AuthPoliciesPage +export default DatabasePoliciesPage diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx index 26ba821eea1..965c22faa2a 100644 --- a/apps/studio/pages/project/[ref]/database/publications/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -1,6 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { PageContainer, PageSection, PageSectionContent, ShimmeringLoader } from 'ui-patterns' +import { PageContainer } from 'ui-patterns/PageContainer' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { PublicationsTables } from '@/components/interfaces/Database/Publications/PublicationsTables' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' diff --git a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx index b9b2d9b1200..e1caff23d17 100644 --- a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx +++ b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx @@ -5,7 +5,7 @@ import { useContext, useEffect } from 'react' import { ReplicationPipelineStatus } from '@/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus' import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import { FormHeader } from '@/components/ui/Forms/FormHeader' import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status' diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx index 3c5c2797df3..2d48e5b2721 100644 --- a/apps/studio/pages/project/[ref]/database/replication/index.tsx +++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx @@ -1,13 +1,13 @@ -import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { Destinations } from '@/components/interfaces/Database/Replication/Destinations' import { ReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram' -import { EmptyReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { UnknownInterface } from '@/components/ui/UnknownInterface' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status' @@ -15,12 +15,24 @@ import type { NextPageWithLayout } from '@/types' const DatabaseReplicationPage: NextPageWithLayout = () => { const { data: selectedProject, isPending } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const showPgReplicate = useIsFeatureEnabled('database:replication') if (!showPgReplicate) { return <UnknownInterface urlBack={`/project/${selectedProject?.ref}/database/schemas`} /> } + if (isHighAvailability) { + return ( + <div className="flex h-full w-full items-center justify-center p-6"> + <HighAvailabilityDisabledEmptyState + title="Replication unavailable on High Availability projects" + description="We're working to bring replication to High Availability projects. Contact support if this is blocking your work." + /> + </div> + ) + } + return ( <PipelineRequestStatusProvider> <ScaffoldContainer> @@ -30,8 +42,8 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { <h3 className="text-foreground text-xl prose">Replication</h3> </div> <p className="prose text-sm max-w-full"> - Deploy read replicas across multiple regions, or replicate database changes to - external data warehouses and analytics platforms + Deploy Read Replicas across multiple regions, or use Pipelines to replicate database + changes to analytics destinations. </p> </div> </ScaffoldSection> @@ -41,31 +53,6 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { <ScaffoldContainer> <GenericSkeletonLoader /> </ScaffoldContainer> - ) : selectedProject?.high_availability ? ( - <> - <EmptyReplicationDiagram /> - - <ScaffoldContainer> - <ScaffoldSection isFullWidth className="pt-6!"> - <Admonition - variant="default" - title="Replication is not available for High Availability projects" - > - Replication is not currently available for projects with High Availability. Please - contact{' '} - <a - href="https://supabase.com/support" - target="_blank" - rel="noopener noreferrer" - className="text-primary underline" - > - support - </a>{' '} - if you are interested in using this feature with your High Availability project. - </Admonition> - </ScaffoldSection> - </ScaffoldContainer> - </> ) : ( <> <ReplicationDiagram /> diff --git a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx index 20d6fd32952..f622cc986f3 100644 --- a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx +++ b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { AWS_REGIONS } from 'shared-data' import { Badge, Button } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ReadReplicaDetails } from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails' import { diff --git a/apps/studio/pages/project/[ref]/database/roles.tsx b/apps/studio/pages/project/[ref]/database/roles.tsx index 72858064332..eb8d11f45d0 100644 --- a/apps/studio/pages/project/[ref]/database/roles.tsx +++ b/apps/studio/pages/project/[ref]/database/roles.tsx @@ -11,7 +11,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { RolesList } from '@/components/interfaces/Database/Roles/RolesList' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/database/schemas.tsx b/apps/studio/pages/project/[ref]/database/schemas.tsx index b9599326390..b57e53abf4a 100644 --- a/apps/studio/pages/project/[ref]/database/schemas.tsx +++ b/apps/studio/pages/project/[ref]/database/schemas.tsx @@ -2,7 +2,7 @@ import { ReactFlowProvider } from '@xyflow/react' import { SchemaGraph } from '@/components/interfaces/Database/Schemas/SchemaGraph' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import type { NextPageWithLayout } from '@/types' const SchemasPage: NextPageWithLayout = () => { diff --git a/apps/studio/pages/project/[ref]/database/tables/[id].tsx b/apps/studio/pages/project/[ref]/database/tables/[id].tsx index f3c71b629cd..500c9c3142b 100644 --- a/apps/studio/pages/project/[ref]/database/tables/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/tables/[id].tsx @@ -7,7 +7,7 @@ import { ColumnList } from '@/components/interfaces/Database/Tables/ColumnList' import DeleteConfirmationDialogs from '@/components/interfaces/TableGridEditor/DeleteConfirmationDialogs' import { SidePanelEditor } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { useTableEditorQuery } from '@/data/table-editor/table-editor-query' import { isTableLike } from '@/data/table-editor/table-editor-types' diff --git a/apps/studio/pages/project/[ref]/database/tables/index.tsx b/apps/studio/pages/project/[ref]/database/tables/index.tsx index ddeebe6f27b..19c890d153c 100644 --- a/apps/studio/pages/project/[ref]/database/tables/index.tsx +++ b/apps/studio/pages/project/[ref]/database/tables/index.tsx @@ -7,7 +7,7 @@ import { TableList } from '@/components/interfaces/Database/Tables/TableList' import DeleteConfirmationDialogs from '@/components/interfaces/TableGridEditor/DeleteConfirmationDialogs' import { SidePanelEditor } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice' import { Entity, isTableLike, postgresTableToEntity } from '@/data/table-editor/table-editor-types' diff --git a/apps/studio/pages/project/[ref]/database/types.tsx b/apps/studio/pages/project/[ref]/database/types.tsx index 1950b0ddc64..85bb0f18adc 100644 --- a/apps/studio/pages/project/[ref]/database/types.tsx +++ b/apps/studio/pages/project/[ref]/database/types.tsx @@ -10,7 +10,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { EnumeratedTypes } from '@/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import type { NextPageWithLayout } from '@/types' const DatabaseEnumeratedTypes: NextPageWithLayout = () => { diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx index 5dd7dbee8d9..b0882b49d97 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx @@ -1,5 +1,5 @@ import { EdgeFunctionDetails } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 7dc16f1c30e..6af3b79b1ad 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -13,11 +13,11 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { EdgeFunctionOverview } from '@/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview' import { EdgeFunctionRecentInvocations } from '@/components/interfaces/Functions/EdgeFunctionRecentInvocations' import ReportWidget from '@/components/interfaces/Reports/ReportWidget' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' import AreaChart from '@/components/ui/Charts/AreaChart' import StackedBarChart from '@/components/ui/Charts/StackedBarChart' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { FunctionsCombinedStatsVariables, useFunctionsCombinedStatsQuery, diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx index db6a67fb74f..e83fcaca488 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx index 3052a78b1c2..d1bc886bb83 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index 07e4eb8bbb9..35c3eb790c6 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' import React, { useMemo, useRef } from 'react' import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -30,9 +30,9 @@ import { EdgeFunctionsListItem } from '@/components/interfaces/Functions/EdgeFun import { FunctionsEmptyState } from '@/components/interfaces/Functions/FunctionsEmptyState' import { TerminalInstructionsDialog } from '@/components/interfaces/Functions/TerminalInstructionsDialog' import { useFunctionsListShortcuts } from '@/components/interfaces/Functions/useFunctionsListShortcuts' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import EdgeFunctionsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx index 428d2974266..2199478aa20 100644 --- a/apps/studio/pages/project/[ref]/index.tsx +++ b/apps/studio/pages/project/[ref]/index.tsx @@ -1,5 +1,5 @@ import { ProjectHome } from '@/components/interfaces/ProjectHome/Home' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ProjectLayoutWithAuth } from '@/components/layouts/ProjectLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx index 27f42c19b2d..2e88ba89ded 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { useEffect, useMemo } from 'react' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx index 970de70a53d..b8c4ddbb007 100644 --- a/apps/studio/pages/project/[ref]/integrations/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/index.tsx @@ -4,7 +4,6 @@ import { Search } from 'lucide-react' import { useRouter } from 'next/router' import { parseAsString, useQueryState } from 'nuqs' import { useMemo, type ReactNode } from 'react' -import { ShimmeringLoader } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -16,6 +15,7 @@ import { PageHeaderTitle, } from 'ui-patterns/PageHeader' import { PageSection, PageSectionContent, PageSectionMeta } from 'ui-patterns/PageSection' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { diff --git a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx index c5110601a92..1720f34c65c 100644 --- a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx @@ -1,9 +1,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/cron-logs.tsx b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx index 8b071de599b..8ef5847fbec 100644 --- a/apps/studio/pages/project/[ref]/logs/cron-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx b/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx index 5f69d2b3650..92f517db324 100644 --- a/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx @@ -5,7 +5,7 @@ import { LogoLoader } from 'ui' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx b/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx index f124efca96c..8296f0cf71c 100644 --- a/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/edge-logs.tsx b/apps/studio/pages/project/[ref]/logs/edge-logs.tsx index 30647d57c2f..d004cf8c814 100644 --- a/apps/studio/pages/project/[ref]/logs/edge-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/edge-logs.tsx @@ -3,7 +3,7 @@ import { parseAsString, useQueryState } from 'nuqs' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 17a086e7590..c246e2da385 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -1,18 +1,23 @@ import { useMonaco } from '@monaco-editor/react' import { useLocalStorage } from '@uidotdev/usehooks' -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import dayjs from 'dayjs' import type { editor } from 'monaco-editor' import { useRouter } from 'next/router' import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' +import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' +import { + detectLogSource, + looksLikeLegacyLogsQuery, + rewriteLogsSqlWithAI, +} from '@/components/interfaces/Settings/Logs/logs-sql-rewrite' import { EXPLORER_DATEPICKER_HELPERS, getDefaultHelper, + getLogsTemplates, LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD, - TEMPLATES, } from '@/components/interfaces/Settings/Logs/Logs.constants' import { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import { LogData, LogsWarning, LogTemplate } from '@/components/interfaces/Settings/Logs/Logs.types' @@ -25,12 +30,14 @@ import { buildLogQueryParams, resolveLogDateRange, } from '@/components/interfaces/Settings/Logs/logsDateRange' -import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel' +import { LogsExplorerOtelBanner } from '@/components/interfaces/Settings/Logs/LogsExplorerOtelBanner' +import { LogsQueryPanel } from '@/components/interfaces/Settings/Logs/LogsQueryPanel' import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { DiffEditor } from '@/components/ui/DiffEditor' import LoadingOpacity from '@/components/ui/LoadingOpacity' import ShimmerLine from '@/components/ui/ShimmerLine' import { useContentQuery } from '@/data/content/content-query' @@ -38,37 +45,53 @@ import { UpsertContentPayload, useContentUpsertMutation, } from '@/data/content/content-upsert-mutation' -import useLogsQuery from '@/hooks/analytics/useLogsQuery' +import { constructHeaders } from '@/data/fetchers' +import { fetchOtelLogKeys } from '@/data/logs/otel-log-keys-query' +import { useLogsQuery } from '@/hooks/analytics/useLogsQuery' import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt' import { uuidv4 } from '@/lib/helpers' import { useProfile } from '@/lib/profile' import { useTrack } from '@/lib/telemetry/track' import type { LogSqlSnippets, NextPageWithLayout } from '@/types' +type SaveQueryFormValues = { name: string; description?: string } + const LOCAL_PLACEHOLDER_QUERY = 'select\n timestamp, event_message, metadata\n from edge_logs limit 5' const PLATFORM_PLACEHOLDER_QUERY = 'select\n cast(timestamp as datetime) as timestamp,\n event_message, metadata \nfrom edge_logs \nlimit 5' +const OTEL_PLACEHOLDER_QUERY = + "select\n timestamp,\n event_message,\n log_attributes\nfrom logs\nwhere source = 'edge_logs'\norder by timestamp desc\nlimit 5" + +const otelSourceQuery = (source: string) => + `select\n timestamp,\n event_message,\n log_attributes\nfrom logs\nwhere source = '${source}'\norder by timestamp desc\nlimit 100` + export const LogsExplorerPage: NextPageWithLayout = () => { useEditorHints() const monaco = useMonaco() const router = useRouter() const { profile } = useProfile() + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() const { ref, q, queryId } = useParams() const track = useTrack() const projectRef = ref as string const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template']) + const useOtelEndpoint = useFlag('otelLegacyLogs') const allTemplates = useMemo(() => { - if (logsShowMetadataIpTemplate) return TEMPLATES - else return TEMPLATES.filter((x) => x.label !== 'Metadata IP') - }, [logsShowMetadataIpTemplate]) + const templates = getLogsTemplates(useOtelEndpoint) + if (logsShowMetadataIpTemplate) return templates + else return templates.filter((x) => x.label !== 'Metadata IP') + }, [logsShowMetadataIpTemplate, useOtelEndpoint]) const editorRef = useRef<editor.IStandaloneCodeEditor>(null) const [editorId] = useState<string>(uuidv4()) @@ -91,25 +114,31 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const [datePickerValue, setDatePickerValue] = useState<DatePickerValue>(initialDatePickerValue) const { logsDefaultQuery } = useCustomContent(['logs:default_query']) - const PLACEHOLDER_QUERY = IS_PLATFORM - ? (logsDefaultQuery ?? PLATFORM_PLACEHOLDER_QUERY) - : LOCAL_PLACEHOLDER_QUERY + const PLACEHOLDER_QUERY = useOtelEndpoint + ? OTEL_PLACEHOLDER_QUERY + : IS_PLATFORM + ? (logsDefaultQuery ?? PLATFORM_PLACEHOLDER_QUERY) + : LOCAL_PLACEHOLDER_QUERY const [editorValue, setEditorValue] = useState<string>(PLACEHOLDER_QUERY) const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false) const [warnings, setWarnings] = useState<LogsWarning[]>([]) const [selectedLog, setSelectedLog] = useState<LogData | null>(null) + const [rewriteProposal, setRewriteProposal] = useState<{ + original: string + modified: string + } | null>(null) + const [isRewriting, setIsRewriting] = useState<boolean>(false) + const [rewriteBannerDismissed, setRewriteBannerDismissed] = useLocalStorage<boolean>( + `project-${projectRef}-logs-rewrite-banner-dismissed`, + false + ) const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>( `project-content-${projectRef}-recent-log-sql`, [] ) - const [useOtelEndpoint, setUseOtelEndpoint] = useLocalStorage<boolean>( - `logs-explorer-use-otel-endpoint-${projectRef}`, - false - ) - const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') const entitledToAuditLogDays = getEntitlementNumericValue() @@ -148,6 +177,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const results = logData const isLoading = logsLoading + const showRewriteCTA = useOtelEndpoint && looksLikeLegacyLogsQuery(editorValue) + const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({ onError: (e) => { const error = e as { message: string } @@ -200,6 +231,52 @@ export const LogsExplorerPage: NextPageWithLayout = () => { addRecentLogSqlSnippet({ sql: template.searchString }) } + const handleRewrite = async () => { + const currentSql = editorRef.current?.getValue() ?? editorValue + if (!currentSql.trim()) { + toast.info('Write a query to rewrite first') + return + } + setIsRewriting(true) + try { + const headerData = await constructHeaders() + const source = detectLogSource(currentSql) + const availableKeys = source + ? await fetchOtelLogKeys({ projectRef, source }).catch(() => undefined) + : undefined + const rewritten = await rewriteLogsSqlWithAI({ + sql: currentSql, + projectRef, + connectionString: project?.connectionString, + orgSlug: organization?.slug, + authorizationHeader: headerData.get('Authorization'), + availableKeys, + }) + // The editor may have changed while awaiting key discovery and the AI call; + // don't offer a proposal that would clobber intervening edits. + const latestSql = editorRef.current?.getValue() ?? editorValue + if (latestSql !== currentSql) { + toast.info('The query changed while rewriting. Please try again.') + return + } + setRewriteProposal({ original: currentSql, modified: rewritten }) + } catch (error) { + toast.error(`Couldn't rewrite the query: ${(error as Error).message}`) + } finally { + setIsRewriting(false) + } + } + + const acceptRewrite = () => { + if (!rewriteProposal) return + editorRef.current?.setValue(rewriteProposal.modified) + setEditorValue(rewriteProposal.modified) + setRewriteProposal(null) + toast.success('Applied the ClickHouse rewrite') + } + + const discardRewrite = () => setRewriteProposal(null) + const handleRun = (value?: string | React.MouseEvent) => { track('log_explorer_query_run_button_clicked', { is_saved_query: !!queryId }) @@ -230,12 +307,20 @@ export const LogsExplorerPage: NextPageWithLayout = () => { if (editorRef.current && monaco) { const editorModel = editorRef.current?.getModel() const currentValue = editorRef.current.getValue() - const index = currentValue.indexOf('from') - const updatedValue = - index < 0 - ? `${currentValue}${source}` - : `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}` + let updatedValue: string + if (useOtelEndpoint) { + const sourceFilter = /source\s*=\s*'[^']*'/i + updatedValue = sourceFilter.test(currentValue) + ? currentValue.replace(sourceFilter, `source = '${source}'`) + : otelSourceQuery(source) + } else { + const index = currentValue.indexOf('from') + updatedValue = + index < 0 + ? `${currentValue}${source}` + : `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}` + } editorRef.current.pushUndoStop() editorRef.current.executeEdits(`insert-identifier`, [ @@ -249,8 +334,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => { } } - type SaveQueryFormValues = { name: string; description?: string } - const handleCreateQuery = async (values: SaveQueryFormValues) => { if (!projectRef) return console.error('Project ref is required') if (!profile) return console.error('Profile is required') @@ -281,13 +364,15 @@ export const LogsExplorerPage: NextPageWithLayout = () => { async function handleOnSave() { if (!projectRef) return console.error('Project ref is required') + const currentSql = editorRef.current?.getValue() ?? editorValue + // if we have a queryId, we are editing a saved query if (queryId && query) { await upsertContent({ projectRef: projectRef!, payload: { ...query, - content: { ...(query.content as LogSqlSnippets.Content), sql: editorValue }, + content: { ...(query.content as LogSqlSnippets.Content), sql: currentSql }, }, }) @@ -323,14 +408,21 @@ export const LogsExplorerPage: NextPageWithLayout = () => { })) } + const querySql = (query?.content as LogSqlSnippets.Content | undefined)?.sql useEffect(() => { if (search) { setEditorValue(search) - } else if (q) { + } else if (q && !queryId) { setEditorValue(q) setSearch(q) + } else if (queryId && querySql) { + setEditorValue(querySql) + editorRef.current?.setValue(querySql) + } else if (!queryId) { + setEditorValue(PLACEHOLDER_QUERY) + editorRef.current?.setValue(PLACEHOLDER_QUERY) } - }, [q, search, setSearch]) + }, [q, search, queryId, querySql, setSearch, PLACEHOLDER_QUERY]) useEffect(() => { // prevents overwriting when the user selects a helper. @@ -386,20 +478,55 @@ export const LogsExplorerPage: NextPageWithLayout = () => { templates={allTemplates.filter((template) => template.mode === 'custom')} onSelectTemplate={onSelectTemplate} warnings={warnings} - useOtel={useOtelEndpoint} - onUseOtelChange={setUseOtelEndpoint} + showRewriteAction={showRewriteCTA && rewriteBannerDismissed} + isRewriting={isRewriting} + onRewrite={handleRewrite} /> + {showRewriteCTA && !rewriteBannerDismissed && ( + <LogsExplorerOtelBanner + isRewriting={isRewriting} + onRewrite={handleRewrite} + onDismiss={() => setRewriteBannerDismissed(true)} + /> + )} <ShimmerLine active={isLoading} /> - <CodeEditor - // Ensure we reset the editor to the query content whenever the selected query changes - key={queryId} - id={editorId} - editorRef={editorRef} - language="pgsql" - defaultValue={editorValue} - onInputChange={(v) => setEditorValue(v || '')} - actions={{ runQuery: { enabled: true, callback: handleRun } }} - /> + <div className="relative h-full"> + <CodeEditor + // Ensure we reset the editor to the query content whenever the selected query changes + key={queryId} + id={editorId} + editorRef={editorRef} + language="pgsql" + defaultValue={editorValue} + onInputChange={(v) => setEditorValue(v || '')} + actions={{ runQuery: { enabled: true, callback: handleRun } }} + /> + {rewriteProposal && ( + <div className="absolute inset-0 z-10 flex flex-col bg-studio"> + <div className="flex items-center justify-between gap-2 border-b bg-surface-100 px-4 py-2"> + <span className="text-xs text-foreground-light"> + Review the ClickHouse SQL rewrite before accepting it + </span> + <div className="flex items-center gap-2"> + <Button variant="default" size="tiny" onClick={discardRewrite}> + Discard + </Button> + <Button variant="primary" size="tiny" onClick={acceptRewrite}> + Accept + </Button> + </div> + </div> + <div className="min-h-0 flex-1"> + <DiffEditor + language="pgsql" + original={rewriteProposal.original} + modified={rewriteProposal.modified} + options={{ renderSideBySide: true, renderGutterMenu: false }} + /> + </div> + </div> + )} + </div> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel collapsible minSize="5" className="overflow-auto"> diff --git a/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx b/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx index a362c7cca86..dd8d11afd9c 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { Button } from 'ui' import RecentQueriesItem from '@/components/interfaces/Settings/Logs/RecentQueriesItem' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import Table from '@/components/to-be-cleaned/Table' import LogsExplorerHeader from '@/components/ui/Logs/LogsExplorerHeader' diff --git a/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx b/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx index fc48a8a2eb2..976ce2c93f1 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx @@ -3,8 +3,8 @@ import { Save } from 'lucide-react' import Link from 'next/link' import { Loading } from 'ui' -import LogsSavedQueriesItem from '@/components/interfaces/Settings/Logs/Logs.SavedQueriesItem' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { SavedQueriesItem } from '@/components/interfaces/Settings/Logs/Logs.SavedQueriesItem' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import Table from '@/components/to-be-cleaned/Table' import LogsExplorerHeader from '@/components/ui/Logs/LogsExplorerHeader' @@ -44,7 +44,7 @@ export const LogsSavedPage: NextPageWithLayout = () => { </> } body={saved.map((item) => ( - <LogsSavedQueriesItem key={item.id} item={item} /> + <SavedQueriesItem key={item.id} item={item} /> ))} /> </div> diff --git a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx index 01c57640504..d5af19ee7cd 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx @@ -1,11 +1,11 @@ -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import { CodeIcon } from 'lucide-react' import { useState } from 'react' import { Button, cn, Popover, PopoverContent, PopoverTrigger } from 'ui' -import { TEMPLATES } from '@/components/interfaces/Settings/Logs/Logs.constants' +import { getLogsTemplates } from '@/components/interfaces/Settings/Logs/Logs.constants' import type { LogTemplate } from '@/components/interfaces/Settings/Logs/Logs.types' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import CardButton from '@/components/ui/CardButton' import LogsExplorerHeader from '@/components/ui/Logs/LogsExplorerHeader' @@ -17,14 +17,16 @@ export const LogsTemplatesPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() const { logsTemplates: isTemplatesEnabled, logsShowMetadataIpTemplate: showMetadataIpTemplate } = useIsFeatureEnabled(['logs:templates', 'logs:show_metadata_ip_template']) + const useOtelEndpoint = useFlag('otelLegacyLogs') if (!isTemplatesEnabled) { return <UnknownInterface urlBack={`/project/${projectRef}/logs/explorer`} /> } + const templates = getLogsTemplates(useOtelEndpoint) const allTemplates = showMetadataIpTemplate - ? TEMPLATES - : TEMPLATES.filter((template) => template.label !== 'Metadata IP') + ? templates + : templates.filter((template) => template.label !== 'Metadata IP') return ( <div className="mx-auto h-full w-full px-5 py-6"> diff --git a/apps/studio/pages/project/[ref]/logs/index.tsx b/apps/studio/pages/project/[ref]/logs/index.tsx index 123db944bb7..06d1df73ac9 100644 --- a/apps/studio/pages/project/[ref]/logs/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/index.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react' import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { UnifiedLogs } from '@/components/interfaces/UnifiedLogs/UnifiedLogs' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ProjectLayout } from '@/components/layouts/ProjectLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/logs/multigres-logs.tsx b/apps/studio/pages/project/[ref]/logs/multigres-logs.tsx index 768b40b4a37..0fc9658073b 100644 --- a/apps/studio/pages/project/[ref]/logs/multigres-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/multigres-logs.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx b/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx index 32140a369e3..e07c4e83812 100644 --- a/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx index 14a9f06e382..04e2874a64a 100644 --- a/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx index f10b0e34cb7..0ba4b7024ee 100644 --- a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx @@ -4,7 +4,7 @@ import { LogoLoader } from 'ui' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx b/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx index 0061c355c7e..129b54be725 100644 --- a/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx @@ -3,7 +3,7 @@ import { parseAsString, useQueryState } from 'nuqs' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx b/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx index d6cb92f6a47..c2d02059470 100644 --- a/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx @@ -4,7 +4,7 @@ import { parseAsString, useQueryState } from 'nuqs' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' import { LogsTableEmptyState } from '@/components/interfaces/Settings/Logs/LogsTableEmptyState' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx b/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx index 5a3be9480fa..9f123df0b83 100644 --- a/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/replication-logs.tsx b/apps/studio/pages/project/[ref]/logs/replication-logs.tsx index 211f35547fc..5a8637a513b 100644 --- a/apps/studio/pages/project/[ref]/logs/replication-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/replication-logs.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/logs/storage-logs.tsx b/apps/studio/pages/project/[ref]/logs/storage-logs.tsx index c659b12307f..5bdfde11ab7 100644 --- a/apps/studio/pages/project/[ref]/logs/storage-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/storage-logs.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx index 5fa26d7ab33..3200df9cf70 100644 --- a/apps/studio/pages/project/[ref]/merge.tsx +++ b/apps/studio/pages/project/[ref]/merge.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Button, cn, NavMenu, NavMenuItem } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { useIsPgDeltaDiffEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' diff --git a/apps/studio/pages/project/[ref]/observability/[id].tsx b/apps/studio/pages/project/[ref]/observability/[id].tsx index cf2ddf4d1d6..98b07c40bea 100644 --- a/apps/studio/pages/project/[ref]/observability/[id].tsx +++ b/apps/studio/pages/project/[ref]/observability/[id].tsx @@ -1,6 +1,6 @@ import ReportPadding from '@/components/interfaces/Reports/ReportPadding' import Reports from '@/components/interfaces/Reports/Reports' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/observability/api-overview.tsx b/apps/studio/pages/project/[ref]/observability/api-overview.tsx index f50b4394f18..07f7ee7fb81 100644 --- a/apps/studio/pages/project/[ref]/observability/api-overview.tsx +++ b/apps/studio/pages/project/[ref]/observability/api-overview.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import { ErrorCountsChartRenderer, NetworkTrafficRenderer, @@ -16,13 +17,16 @@ import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav' import ReportWidget from '@/components/interfaces/Reports/ReportWidget' import { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' +import { DocsButton } from '@/components/ui/DocsButton' import { ObservabilityLink } from '@/components/ui/ObservabilityLink' import { useApiReport } from '@/data/reports/api-report-query' import { useReportDateRange } from '@/hooks/misc/useReportDateRange' import type { NextPageWithLayout } from '@/types' +const REPORT_TITLE = 'API Gateway' + export const ApiReport: NextPageWithLayout = () => { const report = useApiReport() @@ -94,10 +98,10 @@ export const ApiReport: NextPageWithLayout = () => { return ( <ReportPadding> - <ReportHeader title="API Gateway" showDatabaseSelector={false} /> + <ReportHeader title={REPORT_TITLE} showDatabaseSelector={false} /> <ReportStickyNav content={ - <div className="flex items-center gap-3"> + <div className="flex w-full items-center gap-3"> <ReportFilterBar onRemoveFilters={removeFilters} onDatepickerChange={handleDatepickerChange} @@ -109,9 +113,10 @@ export const ApiReport: NextPageWithLayout = () => { filters={filters} datepickerHelpers={datePickerHelpers} initialDatePickerValue={datePickerValue} - className="w-full" + className="flex-1 min-w-0" showDatabaseSelector={false} /> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.apiGateway} topic={REPORT_TITLE} /> <UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} diff --git a/apps/studio/pages/project/[ref]/observability/auth.tsx b/apps/studio/pages/project/[ref]/observability/auth.tsx index e18c7bea0fc..3497dc67c99 100644 --- a/apps/studio/pages/project/[ref]/observability/auth.tsx +++ b/apps/studio/pages/project/[ref]/observability/auth.tsx @@ -6,6 +6,7 @@ import { parseAsJson, useQueryState } from 'nuqs' import { useState } from 'react' import { Button } from 'ui' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar' import ReportHeader from '@/components/interfaces/Reports/ReportHeader' import ReportPadding from '@/components/interfaces/Reports/ReportPadding' @@ -25,10 +26,11 @@ import { } from '@/components/interfaces/Reports/v2/ReportsSelectFilter' import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' import type { ChartHighlightAction } from '@/components/ui/Charts/ChartHighlightActions' import { ReportSettings } from '@/components/ui/Charts/ReportSettings' +import { DocsButton } from '@/components/ui/DocsButton' import { ObservabilityLink } from '@/components/ui/ObservabilityLink' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { @@ -58,6 +60,8 @@ AuthReport.getLayout = (page) => ( export type UpdateDateRange = (from: string, to: string) => void export default AuthReport +const REPORT_TITLE = 'Auth' + const AuthUsage = () => { const { ref } = useParams() const chartSyncId = `auth-report` @@ -209,11 +213,12 @@ const AuthUsage = () => { return ( <> - <ReportHeader title="Auth" showDatabaseSelector={false} /> + <ReportHeader title={REPORT_TITLE} showDatabaseSelector={false} /> <ReportStickyNav content={ - <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> + <div className="flex flex-col gap-2 w-full"> + <div className="ml-auto flex items-center gap-2 flex-wrap"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.auth} topic={REPORT_TITLE} /> <ShortcutTooltip shortcutId={SHORTCUT_IDS.OBSERVABILITY_REFRESH} label="Refresh report" diff --git a/apps/studio/pages/project/[ref]/observability/database.tsx b/apps/studio/pages/project/[ref]/observability/database.tsx index 2887ab606da..ebed8ef4feb 100644 --- a/apps/studio/pages/project/[ref]/observability/database.tsx +++ b/apps/studio/pages/project/[ref]/observability/database.tsx @@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Alert, AlertDescription, Button } from 'ui' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import ReportHeader from '@/components/interfaces/Reports/ReportHeader' import ReportPadding from '@/components/interfaces/Reports/ReportPadding' import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants' @@ -18,13 +19,14 @@ import { POOLING_OPTIMIZATIONS } from '@/components/interfaces/Settings/Database import DiskSizeConfigurationModal from '@/components/interfaces/Settings/Database/DiskSizeConfigurationModal' import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' import Table from '@/components/to-be-cleaned/Table' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import type { MultiAttribute } from '@/components/ui/Charts/ComposedChart.utils' import { LazyComposedChartHandler } from '@/components/ui/Charts/ComposedChartHandler' import { ReportSettings } from '@/components/ui/Charts/ReportSettings' +import { DocsButton } from '@/components/ui/DocsButton' import { ObservabilityLink } from '@/components/ui/ObservabilityLink' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { analyticsKeys } from '@/data/analytics/keys' @@ -65,6 +67,8 @@ DatabaseReport.getLayout = (page) => ( export type UpdateDateRange = (from: string, to: string) => void export default DatabaseReport +const REPORT_TITLE = 'Database' + const DatabaseUsage = () => { const { db, chart, ref } = useParams() const { data: project } = useSelectedProjectQuery() @@ -227,10 +231,11 @@ const DatabaseUsage = () => { return ( <> - <ReportHeader showDatabaseSelector title="Database" /> + <ReportHeader showDatabaseSelector title={REPORT_TITLE} /> <ReportStickyNav content={ - <> + <div className="ml-auto flex items-center gap-2 flex-wrap"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.database} topic={REPORT_TITLE} /> <ShortcutTooltip shortcutId={SHORTCUT_IDS.OBSERVABILITY_REFRESH} label="Refresh report" @@ -275,7 +280,7 @@ const DatabaseUsage = () => { </div> )} </div> - </> + </div> } > {selectedDateRange && diff --git a/apps/studio/pages/project/[ref]/observability/edge-functions.tsx b/apps/studio/pages/project/[ref]/observability/edge-functions.tsx index f322b664da3..1c8c9392d9d 100644 --- a/apps/studio/pages/project/[ref]/observability/edge-functions.tsx +++ b/apps/studio/pages/project/[ref]/observability/edge-functions.tsx @@ -6,6 +6,7 @@ import { parseAsJson, useQueryState } from 'nuqs' import { useMemo, useState } from 'react' import { Button } from 'ui' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import ReportHeader from '@/components/interfaces/Reports/ReportHeader' import ReportPadding from '@/components/interfaces/Reports/ReportPadding' import { @@ -24,10 +25,11 @@ import { } from '@/components/interfaces/Reports/v2/ReportsSelectFilter' import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' import { ReportSettings } from '@/components/ui/Charts/ReportSettings' import { useChartHoverState } from '@/components/ui/Charts/useChartHoverState' +import { DocsButton } from '@/components/ui/DocsButton' import { ObservabilityLink } from '@/components/ui/ObservabilityLink' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' @@ -54,6 +56,8 @@ EdgeFunctionsReportV2.getLayout = (page) => ( export default EdgeFunctionsReportV2 +const REPORT_TITLE = 'Edge Functions' + const EdgeFunctionsUsage = () => { const { ref } = useParams() const { data: functions } = useEdgeFunctionsQuery({ @@ -143,11 +147,12 @@ const EdgeFunctionsUsage = () => { return ( <> - <ReportHeader title="Edge Functions" showDatabaseSelector={false} /> + <ReportHeader title={REPORT_TITLE} showDatabaseSelector={false} /> <ReportStickyNav content={ - <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> + <div className="flex flex-col gap-2 w-full"> + <div className="ml-auto flex items-center gap-2 flex-wrap"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.edgeFunctions} topic={REPORT_TITLE} /> <ShortcutTooltip shortcutId={SHORTCUT_IDS.OBSERVABILITY_REFRESH} label="Refresh report" diff --git a/apps/studio/pages/project/[ref]/observability/index.tsx b/apps/studio/pages/project/[ref]/observability/index.tsx index 28358abda0d..38fc4039644 100644 --- a/apps/studio/pages/project/[ref]/observability/index.tsx +++ b/apps/studio/pages/project/[ref]/observability/index.tsx @@ -7,7 +7,7 @@ import { LogoLoader } from 'ui' import { ObservabilityOverview } from '@/components/interfaces/Observability/ObservabilityOverview' import { CreateReportModal } from '@/components/interfaces/Reports/CreateReportModal' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState' import { useContentQuery } from '@/data/content/content-query' diff --git a/apps/studio/pages/project/[ref]/observability/postgrest.tsx b/apps/studio/pages/project/[ref]/observability/postgrest.tsx index e35a32be132..5b837132779 100644 --- a/apps/studio/pages/project/[ref]/observability/postgrest.tsx +++ b/apps/studio/pages/project/[ref]/observability/postgrest.tsx @@ -4,6 +4,7 @@ import { ArrowRight, RefreshCw } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { Button } from 'ui' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar' import ReportHeader from '@/components/interfaces/Reports/ReportHeader' import ReportPadding from '@/components/interfaces/Reports/ReportPadding' @@ -16,8 +17,9 @@ import { LogsDatePicker, } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' +import { DocsButton } from '@/components/ui/DocsButton' import { ObservabilityLink } from '@/components/ui/ObservabilityLink' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useRefreshHandler, useReportDateRange } from '@/hooks/misc/useReportDateRange' @@ -43,6 +45,8 @@ PostgRESTReport.getLayout = (page) => ( export type UpdateDateRange = (from: string, to: string) => void export default PostgRESTReport +const REPORT_TITLE = 'Data API' + const PostgrestReport = () => { const { db, chart } = useParams() const { @@ -115,11 +119,12 @@ const PostgrestReport = () => { return ( <> - <ReportHeader showDatabaseSelector={false} title="Data API" /> + <ReportHeader showDatabaseSelector={false} title={REPORT_TITLE} /> <ReportStickyNav content={ - <div className="flex flex-col gap-2"> - <div className="flex gap-2"> + <div className="flex flex-col gap-2 w-full"> + <div className="ml-auto flex items-center gap-2 flex-wrap"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.dataApi} topic={REPORT_TITLE} /> <ShortcutTooltip shortcutId={SHORTCUT_IDS.OBSERVABILITY_REFRESH} label="Refresh report" diff --git a/apps/studio/pages/project/[ref]/observability/query-insights.tsx b/apps/studio/pages/project/[ref]/observability/query-insights.tsx index 3308e53ea85..21b4002b397 100644 --- a/apps/studio/pages/project/[ref]/observability/query-insights.tsx +++ b/apps/studio/pages/project/[ref]/observability/query-insights.tsx @@ -1,5 +1,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import { QueryInsights } from '@/components/interfaces/QueryInsights/QueryInsights' import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -7,7 +8,6 @@ import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/Observ import { DatabaseSelector } from '@/components/ui/DatabaseSelector' import { DocsButton } from '@/components/ui/DocsButton' import { useReportDateRange } from '@/hooks/misc/useReportDateRange' -import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' const PRESETS = [ @@ -16,6 +16,8 @@ const PRESETS = [ REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS, ] +const REPORT_TITLE = 'Query Insights' + const QueryInsightsReport: NextPageWithLayout = () => { const { selectedDateRange, datePickerValue, datePickerHelpers, handleDatePickerChange } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES) @@ -30,11 +32,9 @@ const QueryInsightsReport: NextPageWithLayout = () => { return ( <div className="h-full flex flex-col"> <div className="w-full mb-0 flex lg:items-center justify-between gap-4 py-2 px-6 lg:flex-row flex-col border-b lg:h-[48px]"> - <h3 className="text-foreground text-xl prose">Query Insights</h3> + <h3 className="text-foreground text-xl prose">{REPORT_TITLE}</h3> <div className="flex items-center gap-2 flex-wrap"> - <DocsButton - href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`} - /> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.queryInsights} topic={REPORT_TITLE} /> <DatabaseSelector /> <Select value={datePickerValue.isHelper ? datePickerValue.text : undefined} diff --git a/apps/studio/pages/project/[ref]/observability/query-performance.tsx b/apps/studio/pages/project/[ref]/observability/query-performance.tsx index 4f7ecbb281a..d3ffbb10055 100644 --- a/apps/studio/pages/project/[ref]/observability/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/observability/query-performance.tsx @@ -1,7 +1,8 @@ import { useParams } from 'common' import { parseAsArrayOf, parseAsInteger, parseAsJson, parseAsString, useQueryStates } from 'nuqs' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import { useIndexAdvisorStatus } from '@/components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' import { useQueryPerformanceSort } from '@/components/interfaces/QueryPerformance/hooks/useQueryPerformanceSort' import { QueryPerformance } from '@/components/interfaces/QueryPerformance/QueryPerformance' @@ -16,9 +17,10 @@ import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/Observ import { DatabaseSelector } from '@/components/ui/DatabaseSelector' import { DocsButton } from '@/components/ui/DocsButton' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' +const REPORT_TITLE = 'Query Performance' + const QueryPerformanceReport: NextPageWithLayout = () => { const { ref } = useParams() const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() @@ -88,11 +90,9 @@ const QueryPerformanceReport: NextPageWithLayout = () => { return ( <div className="h-full flex flex-col"> <div className="w-full mb-0 flex lg:items-center justify-between gap-4 py-4 px-6 lg:flex-row flex-col"> - <h3 className="text-foreground text-xl prose">Query Performance</h3> + <h3 className="text-foreground text-xl prose">{REPORT_TITLE}</h3> <div className="flex items-center gap-2 flex-wrap"> - <DocsButton - href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`} - /> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.queryPerformance} topic={REPORT_TITLE} /> <DatabaseSelector /> </div> </div> diff --git a/apps/studio/pages/project/[ref]/observability/realtime.tsx b/apps/studio/pages/project/[ref]/observability/realtime.tsx index 0bc16544dde..de1103bd03e 100644 --- a/apps/studio/pages/project/[ref]/observability/realtime.tsx +++ b/apps/studio/pages/project/[ref]/observability/realtime.tsx @@ -5,6 +5,7 @@ import { ArrowRight, RefreshCw } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from 'ui' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar' import ReportHeader from '@/components/interfaces/Reports/ReportHeader' import ReportPadding from '@/components/interfaces/Reports/ReportPadding' @@ -15,8 +16,9 @@ import { useSharedAPIReport } from '@/components/interfaces/Reports/SharedAPIRep import { ReportChartV2 } from '@/components/interfaces/Reports/v2/ReportChartV2' import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' +import { DocsButton } from '@/components/ui/DocsButton' import { ObservabilityLink } from '@/components/ui/ObservabilityLink' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { realtimeReports } from '@/data/reports/v2/realtime.config' @@ -43,6 +45,8 @@ RealtimeReport.getLayout = (page) => ( export type UpdateDateRange = (from: string, to: string) => void export default RealtimeReport +const REPORT_TITLE = 'Realtime' + const RealtimeUsage = () => { const [isRefreshing, setIsRefreshing] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false) @@ -140,11 +144,12 @@ const RealtimeUsage = () => { return ( <> - <ReportHeader showDatabaseSelector={false} title="Realtime" /> + <ReportHeader showDatabaseSelector={false} title={REPORT_TITLE} /> <ReportStickyNav content={ - <div className="flex flex-col gap-3"> - <div className="flex gap-2"> + <div className="flex flex-col gap-3 w-full"> + <div className="ml-auto flex items-center gap-2 flex-wrap"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.realtime} topic={REPORT_TITLE} /> <ShortcutTooltip shortcutId={SHORTCUT_IDS.OBSERVABILITY_REFRESH} label="Refresh report" diff --git a/apps/studio/pages/project/[ref]/observability/storage.tsx b/apps/studio/pages/project/[ref]/observability/storage.tsx index 9e3a0bf874a..fc08c192141 100644 --- a/apps/studio/pages/project/[ref]/observability/storage.tsx +++ b/apps/studio/pages/project/[ref]/observability/storage.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { useState } from 'react' import { Button } from 'ui' +import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants' import { NetworkTrafficRenderer, ResponseSpeedChartRenderer, @@ -25,8 +26,9 @@ import { LogsDatePicker, } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' +import { DocsButton } from '@/components/ui/DocsButton' import { ObservabilityLink } from '@/components/ui/ObservabilityLink' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useStorageReport } from '@/data/reports/storage-report-query' @@ -36,6 +38,8 @@ import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' import type { NextPageWithLayout } from '@/types' +const REPORT_TITLE = 'Storage' + export const StorageReport: NextPageWithLayout = () => { const report = useStorageReport() @@ -88,11 +92,12 @@ export const StorageReport: NextPageWithLayout = () => { return ( <ReportPadding> - <ReportHeader title="Storage" showDatabaseSelector={false} /> + <ReportHeader title={REPORT_TITLE} showDatabaseSelector={false} /> <ReportStickyNav content={ - <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> + <div className="flex flex-col gap-2 w-full"> + <div className="ml-auto flex items-center gap-2 flex-wrap"> + <DocsButton href={OBSERVABILITY_DOCS_HREFS.storage} topic={REPORT_TITLE} /> <ShortcutTooltip shortcutId={SHORTCUT_IDS.OBSERVABILITY_REFRESH} label="Refresh report" diff --git a/apps/studio/pages/project/[ref]/realtime/inspector.tsx b/apps/studio/pages/project/[ref]/realtime/inspector.tsx index a21ef91ebc7..9ae778844af 100644 --- a/apps/studio/pages/project/[ref]/realtime/inspector.tsx +++ b/apps/studio/pages/project/[ref]/realtime/inspector.tsx @@ -1,5 +1,5 @@ import { RealtimeInspector } from '@/components/interfaces/Realtime/Inspector' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import RealtimeLayout from '@/components/layouts/RealtimeLayout/RealtimeLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/realtime/policies.tsx b/apps/studio/pages/project/[ref]/realtime/policies.tsx index fcd4c7555e9..30b796de0e3 100644 --- a/apps/studio/pages/project/[ref]/realtime/policies.tsx +++ b/apps/studio/pages/project/[ref]/realtime/policies.tsx @@ -10,7 +10,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { RealtimePolicies } from '@/components/interfaces/Realtime/Policies' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import RealtimeLayout from '@/components/layouts/RealtimeLayout/RealtimeLayout' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/pages/project/[ref]/realtime/settings.tsx b/apps/studio/pages/project/[ref]/realtime/settings.tsx index beba67221ff..63f00ff7350 100644 --- a/apps/studio/pages/project/[ref]/realtime/settings.tsx +++ b/apps/studio/pages/project/[ref]/realtime/settings.tsx @@ -10,7 +10,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { RealtimeSettings } from '@/components/interfaces/Realtime/RealtimeSettings' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import RealtimeLayout from '@/components/layouts/RealtimeLayout/RealtimeLayout' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/pages/project/[ref]/settings/addons.tsx b/apps/studio/pages/project/[ref]/settings/addons.tsx index 8b729ac0b0a..a1165170b5c 100644 --- a/apps/studio/pages/project/[ref]/settings/addons.tsx +++ b/apps/studio/pages/project/[ref]/settings/addons.tsx @@ -7,7 +7,7 @@ import { } from 'ui-patterns/PageHeader' import { Addons } from '@/components/interfaces/Settings/Addons/Addons' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx b/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx index 0cc6bf6cd69..39fa57ec7e7 100644 --- a/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx +++ b/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx @@ -1,7 +1,7 @@ import { IS_PLATFORM } from 'common' import ApiKeysLayout from '@/components/layouts/APIKeys/APIKeysLayout' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { DisplayApiSettings } from '@/components/ui/ProjectSettings/DisplayApiSettings' import { ToggleLegacyApiKeysPanel } from '@/components/ui/ProjectSettings/ToggleLegacyApiKeys' diff --git a/apps/studio/pages/project/[ref]/settings/billing/usage.tsx b/apps/studio/pages/project/[ref]/settings/billing/usage.tsx index b0714b57378..633e333ebc1 100644 --- a/apps/studio/pages/project/[ref]/settings/billing/usage.tsx +++ b/apps/studio/pages/project/[ref]/settings/billing/usage.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { useEffect } from 'react' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx b/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx index bc32551347a..f87ba5397fe 100644 --- a/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx +++ b/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx @@ -1,5 +1,5 @@ import { DiskManagementForm } from '@/components/interfaces/DiskManagement/DiskManagementForm' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, diff --git a/apps/studio/pages/project/[ref]/settings/dashboard.tsx b/apps/studio/pages/project/[ref]/settings/dashboard.tsx index 39034519963..cac32d1c85b 100644 --- a/apps/studio/pages/project/[ref]/settings/dashboard.tsx +++ b/apps/studio/pages/project/[ref]/settings/dashboard.tsx @@ -11,7 +11,7 @@ import { } from 'ui-patterns/PageHeader' import { DashboardPreferences } from '@/components/interfaces/Settings/General/DashboardPreferences' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { IS_PLATFORM } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/settings/general.tsx b/apps/studio/pages/project/[ref]/settings/general.tsx index 381cd1ecfe3..e043eabef87 100644 --- a/apps/studio/pages/project/[ref]/settings/general.tsx +++ b/apps/studio/pages/project/[ref]/settings/general.tsx @@ -15,7 +15,7 @@ import { DeleteProjectPanel } from '@/components/interfaces/Settings/General/Del import { General } from '@/components/interfaces/Settings/General/General' import { Project } from '@/components/interfaces/Settings/General/Project' import { TransferProjectPanel } from '@/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/settings/jwt/index.tsx b/apps/studio/pages/project/[ref]/settings/jwt/index.tsx index 7161166db71..010c6880259 100644 --- a/apps/studio/pages/project/[ref]/settings/jwt/index.tsx +++ b/apps/studio/pages/project/[ref]/settings/jwt/index.tsx @@ -3,11 +3,11 @@ import { IS_PLATFORM } from 'common' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { JWTSecretKeysTable } from '@/components/interfaces/JwtSecrets/jwt-secret-keys-table' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import JWTKeysLayout from '@/components/layouts/JWTKeys/JWTKeysLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { LocalSetupGuide } from '@/components/ui/LocalSetupGuide' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/pages/project/[ref]/settings/jwt/legacy.tsx b/apps/studio/pages/project/[ref]/settings/jwt/legacy.tsx index 785800f08db..496e9ea4df4 100644 --- a/apps/studio/pages/project/[ref]/settings/jwt/legacy.tsx +++ b/apps/studio/pages/project/[ref]/settings/jwt/legacy.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner' import { JWTSettings } from '@/components/interfaces/JwtSecrets/jwt-settings' import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from '@/components/interfaces/JwtSecrets/jwt.constants' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import JWTKeysLayout from '@/components/layouts/JWTKeys/JWTKeysLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index 92ee1da75d1..3dac9cc99be 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -11,14 +11,14 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { LogDrainDestinationSheetForm } from '@/components/interfaces/LogDrains/LogDrainDestinationSheetForm' import { LogDrains } from '@/components/interfaces/LogDrains/LogDrains' import { LogDrainType } from '@/components/interfaces/LogDrains/LogDrains.constants' import { useEnabledLogDrainTypes } from '@/components/interfaces/LogDrains/useEnabledLogDrainTypes' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' diff --git a/apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx b/apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx index 84f1d9aafbf..7ab9a594333 100644 --- a/apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx +++ b/apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { PlatformWebhooksPage } from '@/components/interfaces/Platform/Webhooks' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/settings/webhooks/index.tsx b/apps/studio/pages/project/[ref]/settings/webhooks/index.tsx index 182a8846891..daaf7ed31e2 100644 --- a/apps/studio/pages/project/[ref]/settings/webhooks/index.tsx +++ b/apps/studio/pages/project/[ref]/settings/webhooks/index.tsx @@ -1,5 +1,5 @@ import { PlatformWebhooksPage } from '@/components/interfaces/Platform/Webhooks' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 24702773c82..0cce211efea 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -4,20 +4,21 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { EditorBaseLayout } from '@/components/layouts/editors/EditorBaseLayout' import { useEditorType } from '@/components/layouts/editors/EditorsLayout.hooks' import SQLEditorLayout from '@/components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from '@/components/layouts/SQLEditorLayout/SQLEditorMenu' -import { useContentIdQuery } from '@/data/content/content-id-query' +import { useSqlSnippetByIdQuery } from '@/data/content/content-id-query' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' -import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle' +import { useSnippets, useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' import type { NextPageWithLayout } from '@/types' @@ -40,9 +41,9 @@ const SqlEditor: NextPageWithLayout = () => { // [Joshen] May need to investigate separately, but occasionally addSnippet doesnt exist in // the snapV2 valtio store for some reason hence why the added typeof check here const canFetchContentBasedOnId = Boolean( - id !== 'new' && typeof snapV2.addSnippet === 'function' && !snippet?.isNotSavedInDatabaseYet + id !== 'new' && typeof snapV2.addSnippet === 'function' && !wasNeverPersisted(snippet?.status) ) - const { data, error, isError } = useContentIdQuery( + const { data, error, isError } = useSqlSnippetByIdQuery( { projectRef: ref, id }, { retry: false, @@ -60,13 +61,13 @@ const SqlEditor: NextPageWithLayout = () => { // behaviour down to a very specific use case too with all these conditionals // More details: https://github.com/supabase/supabase/pull/39389 const snippetMissingImmediatelyAfterCreating = - !!snippet && snippetMissing && previousRoute === 'new' && 'isNotSavedInDatabaseYet' in snippet + !!snippet && snippetMissing && previousRoute === 'new' && wasNeverPersisted(snippet.status) useEffect(() => { if (ref && data && project) { // [Joshen] Check if snippet belongs to the current project if (!IS_PLATFORM || data.project_id === project.id) { - snapV2.setSnippet(ref, data as unknown as SnippetWithContent) + snapV2.setSnippet(ref, data) } else { setLastVisitedSnippet(undefined) router.replace(`/project/${ref}/sql/new`) diff --git a/apps/studio/pages/project/[ref]/sql/examples.tsx b/apps/studio/pages/project/[ref]/sql/examples.tsx index ab28271b591..5980611151f 100644 --- a/apps/studio/pages/project/[ref]/sql/examples.tsx +++ b/apps/studio/pages/project/[ref]/sql/examples.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import { useEffect } from 'react' import SQLExamples from '@/components/interfaces/SQLEditor/SQLTemplates/SQLExamples' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { EditorBaseLayout } from '@/components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from '@/components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from '@/components/layouts/SQLEditorLayout/SQLEditorMenu' diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx index 96a547d102a..3d16de1f041 100644 --- a/apps/studio/pages/project/[ref]/sql/index.tsx +++ b/apps/studio/pages/project/[ref]/sql/index.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { useEffect } from 'react' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { EditorBaseLayout } from '@/components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from '@/components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from '@/components/layouts/SQLEditorLayout/SQLEditorMenu' diff --git a/apps/studio/pages/project/[ref]/sql/templates.tsx b/apps/studio/pages/project/[ref]/sql/templates.tsx index 206ebc16aea..6edae592ab2 100644 --- a/apps/studio/pages/project/[ref]/sql/templates.tsx +++ b/apps/studio/pages/project/[ref]/sql/templates.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import { useEffect } from 'react' import SQLTemplates from '@/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { EditorBaseLayout } from '@/components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from '@/components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from '@/components/layouts/SQLEditorLayout/SQLEditorMenu' diff --git a/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx index 7c78cc182ed..054ae374d9a 100644 --- a/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner' import { AnalyticBucketDetails } from '@/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails' import { useSelectedAnalyticsBucket } from '@/components/interfaces/Storage/AnalyticsBuckets/useSelectedAnalyticsBucket' import { BUCKET_TYPES } from '@/components/interfaces/Storage/Storage.constants' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import { DocsButton } from '@/components/ui/DocsButton' diff --git a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx index 3d360ccdead..3028a520c86 100644 --- a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx @@ -34,7 +34,7 @@ import { PUBLIC_BUCKET_TOOLTIP } from '@/components/interfaces/Storage/Storage.c import StorageBucketsError from '@/components/interfaces/Storage/StorageBucketsError' import { StorageExplorer } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer' import { useBucketPolicyCount } from '@/components/interfaces/Storage/useBucketPolicyCount' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import { StorageExplorerStateContextProvider } from '@/state/storage-explorer' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/storage/files/index.tsx b/apps/studio/pages/project/[ref]/storage/files/index.tsx index d53fc403d61..b8a6a05e75a 100644 --- a/apps/studio/pages/project/[ref]/storage/files/index.tsx +++ b/apps/studio/pages/project/[ref]/storage/files/index.tsx @@ -1,5 +1,5 @@ import { FilesBuckets } from '@/components/interfaces/Storage/FilesBuckets' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { StorageBucketsLayout } from '@/components/layouts/StorageLayout/StorageBucketsLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/storage/files/policies.tsx b/apps/studio/pages/project/[ref]/storage/files/policies.tsx index 90e73a2a9b6..4503650d477 100644 --- a/apps/studio/pages/project/[ref]/storage/files/policies.tsx +++ b/apps/studio/pages/project/[ref]/storage/files/policies.tsx @@ -1,5 +1,5 @@ import { StoragePolicies } from '@/components/interfaces/Storage/StoragePolicies/StoragePolicies' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { StorageBucketsLayout } from '@/components/layouts/StorageLayout/StorageBucketsLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/storage/files/settings.tsx b/apps/studio/pages/project/[ref]/storage/files/settings.tsx index e298a78a4ea..70d1c4a6c9a 100644 --- a/apps/studio/pages/project/[ref]/storage/files/settings.tsx +++ b/apps/studio/pages/project/[ref]/storage/files/settings.tsx @@ -1,5 +1,5 @@ import { StorageSettings } from '@/components/interfaces/Storage/StorageSettings/StorageSettings' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { StorageBucketsLayout } from '@/components/layouts/StorageLayout/StorageBucketsLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/storage/s3.tsx b/apps/studio/pages/project/[ref]/storage/s3.tsx index e57a666a5d5..95d64f00273 100644 --- a/apps/studio/pages/project/[ref]/storage/s3.tsx +++ b/apps/studio/pages/project/[ref]/storage/s3.tsx @@ -1,5 +1,5 @@ import { S3Connection } from '@/components/interfaces/Storage/StorageSettings/S3Connection' -import DefaultLayout from '@/components/layouts/DefaultLayout' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { StorageBucketsLayout } from '@/components/layouts/StorageLayout/StorageBucketsLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/_/[[...routeSlug]].tsx b/apps/studio/pages/project/_/[[...routeSlug]].tsx index 61b83ea75e0..0f276e9fd7e 100644 --- a/apps/studio/pages/project/_/[[...routeSlug]].tsx +++ b/apps/studio/pages/project/_/[[...routeSlug]].tsx @@ -13,8 +13,9 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { parseCatchAllRoute } from '@/compat/next/router' import { Header, LoadingCardView, @@ -35,7 +36,10 @@ import { withAuth } from '@/hooks/misc/withAuth' const GenericProjectPage: NextPage = () => { const router = useRouter() const { slug } = useParams() - const { routeSlug, ...queryParams } = router.query + // Normalise the catch-all path across Next (`routeSlug: string[]`) and + // TanStack (`_splat: string`) so downstream URL building + // (urlRewriterFactory) keeps working — see parseCatchAllRoute. + const { segments: routeSlug, queryParams } = parseCatchAllRoute(router.query, 'routeSlug') const { lastVisitedOrganization } = useLastVisitedOrganization() diff --git a/apps/studio/pages/sign-in-mfa.tsx b/apps/studio/pages/sign-in-mfa.tsx index 85a1f9dc1b6..44942650d91 100644 --- a/apps/studio/pages/sign-in-mfa.tsx +++ b/apps/studio/pages/sign-in-mfa.tsx @@ -9,7 +9,7 @@ import { LogoLoader } from 'ui' import { SignInMfaForm } from '@/components/interfaces/SignIn/SignInMfaForm' import { SignInLayout } from '@/components/layouts/SignInLayout/SignInLayout' import { useAddLoginEvent } from '@/data/misc/audit-login-mutation' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue' import { useTrack } from '@/lib/telemetry/track' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 2ca590998b7..cfb19146dfe 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -1,44 +1,17 @@ import type { NextRequest } from 'next/server' import { IS_PLATFORM } from '@/lib/constants' +import { isHostedSupportedApiPath } from '@/lib/hosted-api-allowlist' export const config = { matcher: '/api/:function*', } -// [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: -const HOSTED_SUPPORTED_API_URLS = [ - '/ai/sql/generate-v4', - '/ai/sql/policy', - '/ai/feedback/rate', - '/ai/code/complete', - '/ai/sql/cron-v2', - '/ai/sql/title-v2', - '/ai/sql/filter-v1', - '/ai/onboarding/design', - '/ai/feedback/classify', - '/ai/docs', - '/ai/sql/parse-client-code', - '/get-ip-address', - '/get-utc-time', - '/get-deployment-commit', - '/check-cname', - '/edge-functions/test', - '/edge-functions/body', - '/generate-attachment-url', - '/incident-status', - '/incident-banner', - '/status-override', - '/api/integrations/stripe-sync', - '/content/graphql', - '/parse-query', -] - +// Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted. +// The allowlist is shared with the TanStack guard (start.ts) — see +// lib/hosted-api-allowlist.ts. export function proxy(request: NextRequest) { - if ( - IS_PLATFORM && - !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) - ) { + if (IS_PLATFORM && !isHostedSupportedApiPath(request.nextUrl.pathname)) { return Response.json( { success: false, message: 'Endpoint not supported on hosted' }, { status: 404 } diff --git a/apps/studio/public/img/icons/claude-icon.svg b/apps/studio/public/img/icons/claude-icon.svg deleted file mode 100644 index 113e9f58d6d..00000000000 --- a/apps/studio/public/img/icons/claude-icon.svg +++ /dev/null @@ -1,6 +0,0 @@ -<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> -<rect width="24" height="24" fill="#000"/> -<g transform="translate(4.8 4.8) scale(0.6)"> -<path d="M5.92381 15.2988L9.85798 13.0912L9.92381 12.8988L9.85798 12.7924H9.66558L9.00735 12.7519L6.75925 12.6912L4.80988 12.6102L2.92127 12.5089L2.44533 12.4076L1.99976 11.8203L2.04533 11.5266L2.44533 11.2583L3.01748 11.3089L4.2833 11.395L6.18203 11.5266L7.55925 11.6076L9.59976 11.8203H9.92381L9.96938 11.6886L9.85798 11.6076L9.77191 11.5266L7.80735 10.195L5.68077 8.78737L4.56684 7.97724L3.96431 7.56711L3.66052 7.1823L3.52887 6.3418L4.07571 5.73927L4.80988 5.7899L4.99722 5.84053L5.74153 6.41268L7.3314 7.64306L9.40735 9.17218L9.71115 9.42534L9.83267 9.33927L9.84786 9.27851L9.71115 9.05066L8.58203 7.01015L7.37697 4.9342L6.84026 4.07344L6.69849 3.55699C6.64786 3.34433 6.61241 3.16712 6.61241 2.94939L7.2352 2.10382L7.5795 1.99243L8.40988 2.10382L8.75925 2.40762L9.27571 3.58737L10.1111 5.4456L11.4074 7.97218L11.7871 8.72154L11.9896 9.41522L12.0656 9.62787H12.1972V9.50635L12.3036 8.08357L12.501 6.33673L12.6934 4.08863L12.7592 3.45572L13.0732 2.69623L13.696 2.2861L14.182 2.51901L14.582 3.09117L14.5263 3.46079L14.2884 5.00509L13.8225 7.42534L13.5187 9.0456H13.696L13.8985 8.84306L14.7187 7.75446L16.096 6.03294L16.7036 5.34939L17.4124 4.59496L17.8681 4.23547H18.7289L19.3618 5.17724L19.0782 6.14939L18.1922 7.27344L17.458 8.22534L16.4048 9.64306L15.7466 10.7772L15.8074 10.8684L15.9643 10.8532L18.3441 10.3469L19.6301 10.114L21.1643 9.85066L21.858 10.1747L21.9339 10.5038L21.6605 11.1772L20.02 11.5823L18.096 11.9671L15.2301 12.6456L15.1947 12.6709L15.2352 12.7215L16.5263 12.8431L17.0782 12.8734H18.4301L20.9466 13.0608L21.6048 13.4962L21.9998 14.0279L21.9339 14.4329L20.9213 14.9494L19.5542 14.6253L16.3643 13.8658L15.2706 13.5924H15.1187V13.6836L16.0301 14.5747L17.701 16.0836L19.7922 18.0279L19.8985 18.5089L19.6301 18.8886L19.3466 18.8481L17.5086 17.4658L16.7998 16.8431L15.1947 15.4912H15.0884V15.6329L15.458 16.1747L17.4124 19.1114L17.5137 20.0127L17.3719 20.3064L16.8656 20.4836L16.3086 20.3823L15.1643 18.7772L13.9846 16.9696L13.0327 15.3494L12.9162 15.4152L12.3542 21.4658L12.0909 21.7747L11.4833 22.0076L10.977 21.6228L10.7086 21L10.977 19.7696L11.301 18.1646L11.5643 16.8886L11.8023 15.3038L11.9441 14.7772L11.9339 14.7418L11.8175 14.757L10.6225 16.3975L8.80482 18.8532L7.36684 20.3924L7.02254 20.5291L6.42507 20.2203L6.48077 19.6684L6.81495 19.1772L8.80482 16.6456L10.0048 15.076L10.7795 14.1696L10.7744 14.038H10.7289L5.44279 17.4709L4.50102 17.5924L4.09596 17.2127L4.14659 16.5899L4.339 16.3874L5.92887 15.2937L5.92381 15.2988Z" fill="#D97757"/> -</g> -</svg> diff --git a/apps/studio/public/img/icons/cursor-icon.svg b/apps/studio/public/img/icons/cursor-icon.svg deleted file mode 100644 index a73aa16db10..00000000000 --- a/apps/studio/public/img/icons/cursor-icon.svg +++ /dev/null @@ -1,12 +0,0 @@ -<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0_2001_474)"> -<rect width="28" height="28" fill="black"/> -<rect x="-0.5" y="-0.5" width="29" height="29" stroke="black" stroke-opacity="0.13"/> -<path d="M22.6411 8.85702L14.4263 4.11425C14.1625 3.96192 13.837 3.96192 13.5732 4.11425L5.35878 8.85702C5.13703 8.98505 5.00012 9.22183 5.00012 9.47829V19.0421C5.00012 19.2982 5.13703 19.5354 5.35878 19.6634L13.5736 24.4062C13.8374 24.5585 14.1629 24.5585 14.4267 24.4062L22.6414 19.6634C22.8632 19.5354 23.0001 19.2986 23.0001 19.0421V9.47829C23.0001 9.22222 22.8632 8.98505 22.6414 8.85702H22.6411ZM22.1251 9.86164L14.1949 23.5971C14.1413 23.6896 13.9997 23.6518 13.9997 23.5446V14.5508C13.9997 14.3711 13.9037 14.2049 13.7479 14.1146L5.95924 9.61791C5.86669 9.56431 5.90448 9.42275 6.01169 9.42275H21.8721C22.0973 9.42275 22.2381 9.66688 22.1254 9.86204H22.1251V9.86164Z" fill="white"/> -</g> -<defs> -<clipPath id="clip0_2001_474"> -<rect width="28" height="28" rx="6" fill="white"/> -</clipPath> -</defs> -</svg> \ No newline at end of file diff --git a/apps/studio/public/img/icons/openai-icon.svg b/apps/studio/public/img/icons/openai-icon.svg deleted file mode 100644 index da774aeaf5e..00000000000 --- a/apps/studio/public/img/icons/openai-icon.svg +++ /dev/null @@ -1,2 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> -<svg width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>OpenAI icon \ No newline at end of file diff --git a/apps/studio/public/monaco-editor/base/common/worker/simpleWorker.nls.js b/apps/studio/public/monaco-editor/base/common/worker/simpleWorker.nls.js deleted file mode 100644 index bcef09a0593..00000000000 --- a/apps/studio/public/monaco-editor/base/common/worker/simpleWorker.nls.js +++ /dev/null @@ -1,11 +0,0 @@ -/*!----------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Version: 0.37.0(20c3f8ff1e9176c954b34c052b2a84358d41a030) - * Released under the MIT license - * https://github.com/microsoft/vscode/blob/main/LICENSE.txt - *-----------------------------------------------------------*/ define( - 'vs/base/common/worker/simpleWorker.nls', - { 'vs/base/common/platform': ['_'] } -) - -//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.js.map diff --git a/apps/studio/public/monaco-editor/base/worker/workerMain.js b/apps/studio/public/monaco-editor/base/worker/workerMain.js deleted file mode 100644 index 8d534b033fb..00000000000 --- a/apps/studio/public/monaco-editor/base/worker/workerMain.js +++ /dev/null @@ -1,11606 +0,0 @@ -/*!----------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Version: 0.37.0(20c3f8ff1e9176c954b34c052b2a84358d41a030) - * Released under the MIT license - * https://github.com/microsoft/vscode/blob/main/LICENSE.txt - *-----------------------------------------------------------*/ ;(function () { - var Y = [ - 'require', - 'exports', - 'vs/editor/common/core/range', - 'vs/editor/common/core/position', - 'vs/base/common/errors', - 'vs/base/common/strings', - 'vs/editor/common/core/offsetRange', - 'vs/editor/common/diff/algorithms/diffAlgorithm', - 'vs/base/common/platform', - 'vs/base/common/event', - 'vs/base/common/assert', - 'vs/base/common/lifecycle', - 'vs/base/common/objects', - 'vs/base/common/uri', - 'vs/base/common/functional', - 'vs/base/common/iterator', - 'vs/base/common/linkedList', - 'vs/base/common/diff/diff', - 'vs/base/common/types', - 'vs/base/common/uint', - 'vs/editor/common/core/characterClassifier', - 'vs/editor/common/core/lineRange', - 'vs/editor/common/core/wordHelper', - 'vs/editor/common/diff/linesDiffComputer', - 'vs/base/common/stopwatch', - 'vs/nls', - 'vs/base/common/arrays', - 'vs/base/common/cache', - 'vs/base/common/diff/diffChange', - 'vs/base/common/keyCodes', - 'vs/base/common/lazy', - 'vs/base/common/hash', - 'vs/base/common/codicons', - 'vs/editor/common/core/selection', - 'vs/editor/common/core/wordCharacterClassifier', - 'vs/editor/common/diff/algorithms/joinSequenceDiffs', - 'vs/editor/common/diff/algorithms/myersDiffAlgorithm', - 'vs/editor/common/diff/algorithms/utils', - 'vs/editor/common/diff/algorithms/dynamicProgrammingDiffing', - 'vs/editor/common/diff/smartLinesDiffComputer', - 'vs/editor/common/diff/standardLinesDiffComputer', - 'vs/editor/common/diff/linesDiffComputers', - 'vs/editor/common/languages/linkComputer', - 'vs/editor/common/languages/supports/inplaceReplaceSupport', - 'vs/editor/common/model', - 'vs/editor/common/model/prefixSumComputer', - 'vs/editor/common/model/mirrorTextModel', - 'vs/editor/common/model/textModelSearch', - 'vs/editor/common/services/unicodeTextModelHighlighter', - 'vs/editor/common/standalone/standaloneEnums', - 'vs/nls!vs/base/common/platform', - 'vs/base/common/process', - 'vs/base/common/path', - 'vs/base/common/cancellation', - 'vs/editor/common/tokenizationRegistry', - 'vs/editor/common/languages', - 'vs/editor/common/services/editorBaseApi', - 'vs/nls!monaco-editor/base/common/worker/simpleWorker', - 'vs/base/common/worker/simpleWorker', - 'vs/editor/common/services/editorSimpleWorker', - ], - X = function (x) { - for (var n = [], R = 0, D = x.length; R < D; R++) n[R] = Y[x[R]] - return n - } - const Ee = this, - Re = typeof global == 'object' ? global : {} - var ae - ;(function (x) { - x.global = Ee - class n { - get isWindows() { - return this._detect(), this._isWindows - } - get isNode() { - return this._detect(), this._isNode - } - get isElectronRenderer() { - return this._detect(), this._isElectronRenderer - } - get isWebWorker() { - return this._detect(), this._isWebWorker - } - get isElectronNodeIntegrationWebWorker() { - return this._detect(), this._isElectronNodeIntegrationWebWorker - } - constructor() { - ;(this._detected = !1), - (this._isWindows = !1), - (this._isNode = !1), - (this._isElectronRenderer = !1), - (this._isWebWorker = !1), - (this._isElectronNodeIntegrationWebWorker = !1) - } - _detect() { - this._detected || - ((this._detected = !0), - (this._isWindows = n._isWindows()), - (this._isNode = typeof module < 'u' && !!module.exports), - (this._isElectronRenderer = - typeof process < 'u' && - typeof process.versions < 'u' && - typeof process.versions.electron < 'u' && - process.type === 'renderer'), - (this._isWebWorker = typeof x.global.importScripts == 'function'), - (this._isElectronNodeIntegrationWebWorker = - this._isWebWorker && - typeof process < 'u' && - typeof process.versions < 'u' && - typeof process.versions.electron < 'u' && - process.type === 'worker')) - } - static _isWindows() { - return typeof navigator < 'u' && - navigator.userAgent && - navigator.userAgent.indexOf('Windows') >= 0 - ? !0 - : typeof process < 'u' - ? process.platform === 'win32' - : !1 - } - } - x.Environment = n - })(ae || (ae = {})) - var ae - ;(function (x) { - class n { - constructor(s, p, L) { - ;(this.type = s), (this.detail = p), (this.timestamp = L) - } - } - x.LoaderEvent = n - class R { - constructor(s) { - this._events = [new n(1, '', s)] - } - record(s, p) { - this._events.push(new n(s, p, x.Utilities.getHighPerformanceTimestamp())) - } - getEvents() { - return this._events - } - } - x.LoaderEventRecorder = R - class D { - record(s, p) {} - getEvents() { - return [] - } - } - ;(D.INSTANCE = new D()), (x.NullLoaderEventRecorder = D) - })(ae || (ae = {})) - var ae - ;(function (x) { - class n { - static fileUriToFilePath(D, i) { - if (((i = decodeURI(i).replace(/%23/g, '#')), D)) { - if (/^file:\/\/\//.test(i)) return i.substr(8) - if (/^file:\/\//.test(i)) return i.substr(5) - } else if (/^file:\/\//.test(i)) return i.substr(7) - return i - } - static startsWith(D, i) { - return D.length >= i.length && D.substr(0, i.length) === i - } - static endsWith(D, i) { - return D.length >= i.length && D.substr(D.length - i.length) === i - } - static containsQueryString(D) { - return /^[^\#]*\?/gi.test(D) - } - static isAbsolutePath(D) { - return /^((http:\/\/)|(https:\/\/)|(file:\/\/)|(\/))/.test(D) - } - static forEachProperty(D, i) { - if (D) { - let s - for (s in D) D.hasOwnProperty(s) && i(s, D[s]) - } - } - static isEmpty(D) { - let i = !0 - return ( - n.forEachProperty(D, () => { - i = !1 - }), - i - ) - } - static recursiveClone(D) { - if ( - !D || - typeof D != 'object' || - D instanceof RegExp || - (!Array.isArray(D) && Object.getPrototypeOf(D) !== Object.prototype) - ) - return D - let i = Array.isArray(D) ? [] : {} - return ( - n.forEachProperty(D, (s, p) => { - p && typeof p == 'object' ? (i[s] = n.recursiveClone(p)) : (i[s] = p) - }), - i - ) - } - static generateAnonymousModule() { - return '===anonymous' + n.NEXT_ANONYMOUS_ID++ + '===' - } - static isAnonymousModule(D) { - return n.startsWith(D, '===anonymous') - } - static getHighPerformanceTimestamp() { - return ( - this.PERFORMANCE_NOW_PROBED || - ((this.PERFORMANCE_NOW_PROBED = !0), - (this.HAS_PERFORMANCE_NOW = - x.global.performance && typeof x.global.performance.now == 'function')), - this.HAS_PERFORMANCE_NOW ? x.global.performance.now() : Date.now() - ) - } - } - ;(n.NEXT_ANONYMOUS_ID = 1), - (n.PERFORMANCE_NOW_PROBED = !1), - (n.HAS_PERFORMANCE_NOW = !1), - (x.Utilities = n) - })(ae || (ae = {})) - var ae - ;(function (x) { - function n(i) { - if (i instanceof Error) return i - const s = new Error(i.message || String(i) || 'Unknown Error') - return i.stack && (s.stack = i.stack), s - } - x.ensureError = n - class R { - static validateConfigurationOptions(s) { - function p(L) { - if (L.phase === 'loading') { - console.error('Loading "' + L.moduleId + '" failed'), - console.error(L), - console.error('Here are the modules that depend on it:'), - console.error(L.neededBy) - return - } - if (L.phase === 'factory') { - console.error('The factory function of "' + L.moduleId + '" has thrown an exception'), - console.error(L), - console.error('Here are the modules that depend on it:'), - console.error(L.neededBy) - return - } - } - if ( - ((s = s || {}), - typeof s.baseUrl != 'string' && (s.baseUrl = ''), - typeof s.isBuild != 'boolean' && (s.isBuild = !1), - typeof s.paths != 'object' && (s.paths = {}), - typeof s.config != 'object' && (s.config = {}), - typeof s.catchError > 'u' && (s.catchError = !1), - typeof s.recordStats > 'u' && (s.recordStats = !1), - typeof s.urlArgs != 'string' && (s.urlArgs = ''), - typeof s.onError != 'function' && (s.onError = p), - Array.isArray(s.ignoreDuplicateModules) || (s.ignoreDuplicateModules = []), - s.baseUrl.length > 0 && (x.Utilities.endsWith(s.baseUrl, '/') || (s.baseUrl += '/')), - typeof s.cspNonce != 'string' && (s.cspNonce = ''), - typeof s.preferScriptTags > 'u' && (s.preferScriptTags = !1), - s.nodeCachedData && - typeof s.nodeCachedData == 'object' && - (typeof s.nodeCachedData.seed != 'string' && (s.nodeCachedData.seed = 'seed'), - (typeof s.nodeCachedData.writeDelay != 'number' || s.nodeCachedData.writeDelay < 0) && - (s.nodeCachedData.writeDelay = 1e3 * 7), - !s.nodeCachedData.path || typeof s.nodeCachedData.path != 'string')) - ) { - const L = n(new Error("INVALID cached data configuration, 'path' MUST be set")) - ;(L.phase = 'configuration'), s.onError(L), (s.nodeCachedData = void 0) - } - return s - } - static mergeConfigurationOptions(s = null, p = null) { - let L = x.Utilities.recursiveClone(p || {}) - return ( - x.Utilities.forEachProperty(s, (h, a) => { - h === 'ignoreDuplicateModules' && typeof L.ignoreDuplicateModules < 'u' - ? (L.ignoreDuplicateModules = L.ignoreDuplicateModules.concat(a)) - : h === 'paths' && typeof L.paths < 'u' - ? x.Utilities.forEachProperty(a, (w, e) => (L.paths[w] = e)) - : h === 'config' && typeof L.config < 'u' - ? x.Utilities.forEachProperty(a, (w, e) => (L.config[w] = e)) - : (L[h] = x.Utilities.recursiveClone(a)) - }), - R.validateConfigurationOptions(L) - ) - } - } - x.ConfigurationOptionsUtil = R - class D { - constructor(s, p) { - if ( - ((this._env = s), - (this.options = R.mergeConfigurationOptions(p)), - this._createIgnoreDuplicateModulesMap(), - this._createSortedPathsRules(), - this.options.baseUrl === '' && - this.options.nodeRequire && - this.options.nodeRequire.main && - this.options.nodeRequire.main.filename && - this._env.isNode) - ) { - let L = this.options.nodeRequire.main.filename, - h = Math.max(L.lastIndexOf('/'), L.lastIndexOf('\\')) - this.options.baseUrl = L.substring(0, h + 1) - } - } - _createIgnoreDuplicateModulesMap() { - this.ignoreDuplicateModulesMap = {} - for (let s = 0; s < this.options.ignoreDuplicateModules.length; s++) - this.ignoreDuplicateModulesMap[this.options.ignoreDuplicateModules[s]] = !0 - } - _createSortedPathsRules() { - ;(this.sortedPathsRules = []), - x.Utilities.forEachProperty(this.options.paths, (s, p) => { - Array.isArray(p) - ? this.sortedPathsRules.push({ from: s, to: p }) - : this.sortedPathsRules.push({ from: s, to: [p] }) - }), - this.sortedPathsRules.sort((s, p) => p.from.length - s.from.length) - } - cloneAndMerge(s) { - return new D(this._env, R.mergeConfigurationOptions(s, this.options)) - } - getOptionsLiteral() { - return this.options - } - _applyPaths(s) { - let p - for (let L = 0, h = this.sortedPathsRules.length; L < h; L++) - if (((p = this.sortedPathsRules[L]), x.Utilities.startsWith(s, p.from))) { - let a = [] - for (let w = 0, e = p.to.length; w < e; w++) a.push(p.to[w] + s.substr(p.from.length)) - return a - } - return [s] - } - _addUrlArgsToUrl(s) { - return x.Utilities.containsQueryString(s) - ? s + '&' + this.options.urlArgs - : s + '?' + this.options.urlArgs - } - _addUrlArgsIfNecessaryToUrl(s) { - return this.options.urlArgs ? this._addUrlArgsToUrl(s) : s - } - _addUrlArgsIfNecessaryToUrls(s) { - if (this.options.urlArgs) - for (let p = 0, L = s.length; p < L; p++) s[p] = this._addUrlArgsToUrl(s[p]) - return s - } - moduleIdToPaths(s) { - if ( - this._env.isNode && - this.options.amdModulesPattern instanceof RegExp && - !this.options.amdModulesPattern.test(s) - ) - return this.isBuild() ? ['empty:'] : ['node|' + s] - let p = s, - L - if (!x.Utilities.endsWith(p, '.js') && !x.Utilities.isAbsolutePath(p)) { - L = this._applyPaths(p) - for (let h = 0, a = L.length; h < a; h++) - (this.isBuild() && L[h] === 'empty:') || - (x.Utilities.isAbsolutePath(L[h]) || (L[h] = this.options.baseUrl + L[h]), - !x.Utilities.endsWith(L[h], '.js') && - !x.Utilities.containsQueryString(L[h]) && - (L[h] = L[h] + '.js')) - } else - !x.Utilities.endsWith(p, '.js') && !x.Utilities.containsQueryString(p) && (p = p + '.js'), - (L = [p]) - return this._addUrlArgsIfNecessaryToUrls(L) - } - requireToUrl(s) { - let p = s - return ( - x.Utilities.isAbsolutePath(p) || - ((p = this._applyPaths(p)[0]), - x.Utilities.isAbsolutePath(p) || (p = this.options.baseUrl + p)), - this._addUrlArgsIfNecessaryToUrl(p) - ) - } - isBuild() { - return this.options.isBuild - } - shouldInvokeFactory(s) { - return !!( - !this.options.isBuild || - x.Utilities.isAnonymousModule(s) || - (this.options.buildForceInvokeFactory && this.options.buildForceInvokeFactory[s]) - ) - } - isDuplicateMessageIgnoredFor(s) { - return this.ignoreDuplicateModulesMap.hasOwnProperty(s) - } - getConfigForModule(s) { - if (this.options.config) return this.options.config[s] - } - shouldCatchError() { - return this.options.catchError - } - shouldRecordStats() { - return this.options.recordStats - } - onError(s) { - this.options.onError(s) - } - } - x.Configuration = D - })(ae || (ae = {})) - var ae - ;(function (x) { - class n { - constructor(a) { - ;(this._env = a), (this._scriptLoader = null), (this._callbackMap = {}) - } - load(a, w, e, b) { - if (!this._scriptLoader) - if (this._env.isWebWorker) this._scriptLoader = new i() - else if (this._env.isElectronRenderer) { - const { preferScriptTags: v } = a.getConfig().getOptionsLiteral() - v ? (this._scriptLoader = new R()) : (this._scriptLoader = new s(this._env)) - } else - this._env.isNode - ? (this._scriptLoader = new s(this._env)) - : (this._scriptLoader = new R()) - let f = { callback: e, errorback: b } - if (this._callbackMap.hasOwnProperty(w)) { - this._callbackMap[w].push(f) - return - } - ;(this._callbackMap[w] = [f]), - this._scriptLoader.load( - a, - w, - () => this.triggerCallback(w), - (v) => this.triggerErrorback(w, v) - ) - } - triggerCallback(a) { - let w = this._callbackMap[a] - delete this._callbackMap[a] - for (let e = 0; e < w.length; e++) w[e].callback() - } - triggerErrorback(a, w) { - let e = this._callbackMap[a] - delete this._callbackMap[a] - for (let b = 0; b < e.length; b++) e[b].errorback(w) - } - } - class R { - attachListeners(a, w, e) { - let b = () => { - a.removeEventListener('load', f), a.removeEventListener('error', v) - }, - f = (g) => { - b(), w() - }, - v = (g) => { - b(), e(g) - } - a.addEventListener('load', f), a.addEventListener('error', v) - } - load(a, w, e, b) { - if (/^node\|/.test(w)) { - let f = a.getConfig().getOptionsLiteral(), - v = p(a.getRecorder(), f.nodeRequire || x.global.nodeRequire), - g = w.split('|'), - S = null - try { - S = v(g[1]) - } catch (E) { - b(E) - return - } - a.enqueueDefineAnonymousModule([], () => S), e() - } else { - let f = document.createElement('script') - f.setAttribute('async', 'async'), - f.setAttribute('type', 'text/javascript'), - this.attachListeners(f, e, b) - const { trustedTypesPolicy: v } = a.getConfig().getOptionsLiteral() - v && (w = v.createScriptURL(w)), f.setAttribute('src', w) - const { cspNonce: g } = a.getConfig().getOptionsLiteral() - g && f.setAttribute('nonce', g), document.getElementsByTagName('head')[0].appendChild(f) - } - } - } - function D(h) { - const { trustedTypesPolicy: a } = h.getConfig().getOptionsLiteral() - try { - return (a ? self.eval(a.createScript('', 'true')) : new Function('true')).call(self), !0 - } catch { - return !1 - } - } - class i { - constructor() { - this._cachedCanUseEval = null - } - _canUseEval(a) { - return ( - this._cachedCanUseEval === null && (this._cachedCanUseEval = D(a)), this._cachedCanUseEval - ) - } - load(a, w, e, b) { - if (/^node\|/.test(w)) { - const f = a.getConfig().getOptionsLiteral(), - v = p(a.getRecorder(), f.nodeRequire || x.global.nodeRequire), - g = w.split('|') - let S = null - try { - S = v(g[1]) - } catch (E) { - b(E) - return - } - a.enqueueDefineAnonymousModule([], function () { - return S - }), - e() - } else { - const { trustedTypesPolicy: f } = a.getConfig().getOptionsLiteral() - if ( - !( - /^((http:)|(https:)|(file:))/.test(w) && - w.substring(0, self.origin.length) !== self.origin - ) && - this._canUseEval(a) - ) { - fetch(w) - .then((g) => { - if (g.status !== 200) throw new Error(g.statusText) - return g.text() - }) - .then((g) => { - ;(g = `${g} -//# sourceURL=${w}`), - (f ? self.eval(f.createScript('', g)) : new Function(g)).call(self), - e() - }) - .then(void 0, b) - return - } - try { - f && (w = f.createScriptURL(w)), importScripts(w), e() - } catch (g) { - b(g) - } - } - } - } - class s { - constructor(a) { - ;(this._env = a), (this._didInitialize = !1), (this._didPatchNodeRequire = !1) - } - _init(a) { - this._didInitialize || - ((this._didInitialize = !0), - (this._fs = a('fs')), - (this._vm = a('vm')), - (this._path = a('path')), - (this._crypto = a('crypto'))) - } - _initNodeRequire(a, w) { - const { nodeCachedData: e } = w.getConfig().getOptionsLiteral() - if (!e || this._didPatchNodeRequire) return - this._didPatchNodeRequire = !0 - const b = this, - f = a('module') - function v(g) { - const S = g.constructor - let E = function (_) { - try { - return g.require(_) - } finally { - } - } - return ( - (E.resolve = function (_, d) { - return S._resolveFilename(_, g, !1, d) - }), - (E.resolve.paths = function (_) { - return S._resolveLookupPaths(_, g) - }), - (E.main = process.mainModule), - (E.extensions = S._extensions), - (E.cache = S._cache), - E - ) - } - f.prototype._compile = function (g, S) { - const E = f.wrap(g.replace(/^#!.*/, '')), - y = w.getRecorder(), - _ = b._getCachedDataPath(e, S), - d = { filename: S } - let C - try { - const N = b._fs.readFileSync(_) - ;(C = N.slice(0, 16)), (d.cachedData = N.slice(16)), y.record(60, _) - } catch { - y.record(61, _) - } - const r = new b._vm.Script(E, d), - u = r.runInThisContext(d), - o = b._path.dirname(S), - c = v(this), - l = [this.exports, c, this, S, o, process, Re, Buffer], - m = u.apply(this.exports, l) - return ( - b._handleCachedData(r, E, _, !d.cachedData, w), b._verifyCachedData(r, E, _, C, w), m - ) - } - } - load(a, w, e, b) { - const f = a.getConfig().getOptionsLiteral(), - v = p(a.getRecorder(), f.nodeRequire || x.global.nodeRequire), - g = - f.nodeInstrumenter || - function (E) { - return E - } - this._init(v), this._initNodeRequire(v, a) - let S = a.getRecorder() - if (/^node\|/.test(w)) { - let E = w.split('|'), - y = null - try { - y = v(E[1]) - } catch (_) { - b(_) - return - } - a.enqueueDefineAnonymousModule([], () => y), e() - } else { - w = x.Utilities.fileUriToFilePath(this._env.isWindows, w) - const E = this._path.normalize(w), - y = this._getElectronRendererScriptPathOrUri(E), - _ = Boolean(f.nodeCachedData), - d = _ ? this._getCachedDataPath(f.nodeCachedData, w) : void 0 - this._readSourceAndCachedData(E, d, S, (C, r, u, o) => { - if (C) { - b(C) - return - } - let c - r.charCodeAt(0) === s._BOM - ? (c = s._PREFIX + r.substring(1) + s._SUFFIX) - : (c = s._PREFIX + r + s._SUFFIX), - (c = g(c, E)) - const l = { filename: y, cachedData: u }, - m = this._createAndEvalScript(a, c, l, e, b) - this._handleCachedData(m, c, d, _ && !u, a), this._verifyCachedData(m, c, d, o, a) - }) - } - } - _createAndEvalScript(a, w, e, b, f) { - const v = a.getRecorder() - v.record(31, e.filename) - const g = new this._vm.Script(w, e), - S = g.runInThisContext(e), - E = a.getGlobalAMDDefineFunc() - let y = !1 - const _ = function () { - return (y = !0), E.apply(null, arguments) - } - return ( - (_.amd = E.amd), - S.call( - x.global, - a.getGlobalAMDRequireFunc(), - _, - e.filename, - this._path.dirname(e.filename) - ), - v.record(32, e.filename), - y ? b() : f(new Error(`Didn't receive define call in ${e.filename}!`)), - g - ) - } - _getElectronRendererScriptPathOrUri(a) { - if (!this._env.isElectronRenderer) return a - let w = a.match(/^([a-z])\:(.*)/i) - return w - ? `file:///${(w[1].toUpperCase() + ':' + w[2]).replace(/\\/g, '/')}` - : `file://${a}` - } - _getCachedDataPath(a, w) { - const e = this._crypto - .createHash('md5') - .update(w, 'utf8') - .update(a.seed, 'utf8') - .update(process.arch, '') - .digest('hex'), - b = this._path.basename(w).replace(/\.js$/, '') - return this._path.join(a.path, `${b}-${e}.code`) - } - _handleCachedData(a, w, e, b, f) { - a.cachedDataRejected - ? this._fs.unlink(e, (v) => { - f.getRecorder().record(62, e), - this._createAndWriteCachedData(a, w, e, f), - v && f.getConfig().onError(v) - }) - : b && this._createAndWriteCachedData(a, w, e, f) - } - _createAndWriteCachedData(a, w, e, b) { - let f = Math.ceil( - b.getConfig().getOptionsLiteral().nodeCachedData.writeDelay * (1 + Math.random()) - ), - v = -1, - g = 0, - S - const E = () => { - setTimeout(() => { - S || (S = this._crypto.createHash('md5').update(w, 'utf8').digest()) - const y = a.createCachedData() - if (!(y.length === 0 || y.length === v || g >= 5)) { - if (y.length < v) { - E() - return - } - ;(v = y.length), - this._fs.writeFile(e, Buffer.concat([S, y]), (_) => { - _ && b.getConfig().onError(_), b.getRecorder().record(63, e), E() - }) - } - }, f * Math.pow(4, g++)) - } - E() - } - _readSourceAndCachedData(a, w, e, b) { - if (!w) this._fs.readFile(a, { encoding: 'utf8' }, b) - else { - let f, - v, - g, - S = 2 - const E = (y) => { - y ? b(y) : --S === 0 && b(void 0, f, v, g) - } - this._fs.readFile(a, { encoding: 'utf8' }, (y, _) => { - ;(f = _), E(y) - }), - this._fs.readFile(w, (y, _) => { - !y && _ && _.length > 0 - ? ((g = _.slice(0, 16)), (v = _.slice(16)), e.record(60, w)) - : e.record(61, w), - E() - }) - } - } - _verifyCachedData(a, w, e, b, f) { - !b || - a.cachedDataRejected || - setTimeout(() => { - const v = this._crypto.createHash('md5').update(w, 'utf8').digest() - b.equals(v) || - (f - .getConfig() - .onError( - new Error( - `FAILED TO VERIFY CACHED DATA, deleting stale '${e}' now, but a RESTART IS REQUIRED` - ) - ), - this._fs.unlink(e, (g) => { - g && f.getConfig().onError(g) - })) - }, Math.ceil(5e3 * (1 + Math.random()))) - } - } - ;(s._BOM = 65279), - (s._PREFIX = '(function (require, define, __filename, __dirname) { '), - (s._SUFFIX = ` -});`) - function p(h, a) { - if (a.__$__isRecorded) return a - const w = function (b) { - h.record(33, b) - try { - return a(b) - } finally { - h.record(34, b) - } - } - return (w.__$__isRecorded = !0), w - } - x.ensureRecordedNodeRequire = p - function L(h) { - return new n(h) - } - x.createScriptLoader = L - })(ae || (ae = {})) - var ae - ;(function (x) { - class n { - constructor(h) { - let a = h.lastIndexOf('/') - a !== -1 ? (this.fromModulePath = h.substr(0, a + 1)) : (this.fromModulePath = '') - } - static _normalizeModuleId(h) { - let a = h, - w - for (w = /\/\.\//; w.test(a); ) a = a.replace(w, '/') - for ( - a = a.replace(/^\.\//g, ''), - w = /\/(([^\/])|([^\/][^\/\.])|([^\/\.][^\/])|([^\/][^\/][^\/]+))\/\.\.\//; - w.test(a); - - ) - a = a.replace(w, '/') - return ( - (a = a.replace( - /^(([^\/])|([^\/][^\/\.])|([^\/\.][^\/])|([^\/][^\/][^\/]+))\/\.\.\//, - '' - )), - a - ) - } - resolveModule(h) { - let a = h - return ( - x.Utilities.isAbsolutePath(a) || - ((x.Utilities.startsWith(a, './') || x.Utilities.startsWith(a, '../')) && - (a = n._normalizeModuleId(this.fromModulePath + a))), - a - ) - } - } - ;(n.ROOT = new n('')), (x.ModuleIdResolver = n) - class R { - constructor(h, a, w, e, b, f) { - ;(this.id = h), - (this.strId = a), - (this.dependencies = w), - (this._callback = e), - (this._errorback = b), - (this.moduleIdResolver = f), - (this.exports = {}), - (this.error = null), - (this.exportsPassedIn = !1), - (this.unresolvedDependenciesCount = this.dependencies.length), - (this._isComplete = !1) - } - static _safeInvokeFunction(h, a) { - try { - return { returnedValue: h.apply(x.global, a), producedError: null } - } catch (w) { - return { returnedValue: null, producedError: w } - } - } - static _invokeFactory(h, a, w, e) { - return h.shouldInvokeFactory(a) - ? h.shouldCatchError() - ? this._safeInvokeFunction(w, e) - : { returnedValue: w.apply(x.global, e), producedError: null } - : { returnedValue: null, producedError: null } - } - complete(h, a, w, e) { - this._isComplete = !0 - let b = null - if (this._callback) - if (typeof this._callback == 'function') { - h.record(21, this.strId) - let f = R._invokeFactory(a, this.strId, this._callback, w) - ;(b = f.producedError), - h.record(22, this.strId), - !b && - typeof f.returnedValue < 'u' && - (!this.exportsPassedIn || x.Utilities.isEmpty(this.exports)) && - (this.exports = f.returnedValue) - } else this.exports = this._callback - if (b) { - let f = x.ensureError(b) - ;(f.phase = 'factory'), - (f.moduleId = this.strId), - (f.neededBy = e(this.id)), - (this.error = f), - a.onError(f) - } - ;(this.dependencies = null), - (this._callback = null), - (this._errorback = null), - (this.moduleIdResolver = null) - } - onDependencyError(h) { - return ( - (this._isComplete = !0), (this.error = h), this._errorback ? (this._errorback(h), !0) : !1 - ) - } - isComplete() { - return this._isComplete - } - } - x.Module = R - class D { - constructor() { - ;(this._nextId = 0), - (this._strModuleIdToIntModuleId = new Map()), - (this._intModuleIdToStrModuleId = []), - this.getModuleId('exports'), - this.getModuleId('module'), - this.getModuleId('require') - } - getMaxModuleId() { - return this._nextId - } - getModuleId(h) { - let a = this._strModuleIdToIntModuleId.get(h) - return ( - typeof a > 'u' && - ((a = this._nextId++), - this._strModuleIdToIntModuleId.set(h, a), - (this._intModuleIdToStrModuleId[a] = h)), - a - ) - } - getStrModuleId(h) { - return this._intModuleIdToStrModuleId[h] - } - } - class i { - constructor(h) { - this.id = h - } - } - ;(i.EXPORTS = new i(0)), - (i.MODULE = new i(1)), - (i.REQUIRE = new i(2)), - (x.RegularDependency = i) - class s { - constructor(h, a, w) { - ;(this.id = h), (this.pluginId = a), (this.pluginParam = w) - } - } - x.PluginDependency = s - class p { - constructor(h, a, w, e, b = 0) { - ;(this._env = h), - (this._scriptLoader = a), - (this._loaderAvailableTimestamp = b), - (this._defineFunc = w), - (this._requireFunc = e), - (this._moduleIdProvider = new D()), - (this._config = new x.Configuration(this._env)), - (this._hasDependencyCycle = !1), - (this._modules2 = []), - (this._knownModules2 = []), - (this._inverseDependencies2 = []), - (this._inversePluginDependencies2 = new Map()), - (this._currentAnonymousDefineCall = null), - (this._recorder = null), - (this._buildInfoPath = []), - (this._buildInfoDefineStack = []), - (this._buildInfoDependencies = []) - } - reset() { - return new p( - this._env, - this._scriptLoader, - this._defineFunc, - this._requireFunc, - this._loaderAvailableTimestamp - ) - } - getGlobalAMDDefineFunc() { - return this._defineFunc - } - getGlobalAMDRequireFunc() { - return this._requireFunc - } - static _findRelevantLocationInStack(h, a) { - let w = (f) => f.replace(/\\/g, '/'), - e = w(h), - b = a.split(/\n/) - for (let f = 0; f < b.length; f++) { - let v = b[f].match(/(.*):(\d+):(\d+)\)?$/) - if (v) { - let g = v[1], - S = v[2], - E = v[3], - y = Math.max(g.lastIndexOf(' ') + 1, g.lastIndexOf('(') + 1) - if (((g = g.substr(y)), (g = w(g)), g === e)) { - let _ = { line: parseInt(S, 10), col: parseInt(E, 10) } - return _.line === 1 && (_.col -= 53), _ - } - } - } - throw new Error('Could not correlate define call site for needle ' + h) - } - getBuildInfo() { - if (!this._config.isBuild()) return null - let h = [], - a = 0 - for (let w = 0, e = this._modules2.length; w < e; w++) { - let b = this._modules2[w] - if (!b) continue - let f = this._buildInfoPath[b.id] || null, - v = this._buildInfoDefineStack[b.id] || null, - g = this._buildInfoDependencies[b.id] - h[a++] = { - id: b.strId, - path: f, - defineLocation: f && v ? p._findRelevantLocationInStack(f, v) : null, - dependencies: g, - shim: null, - exports: b.exports, - } - } - return h - } - getRecorder() { - return ( - this._recorder || - (this._config.shouldRecordStats() - ? (this._recorder = new x.LoaderEventRecorder(this._loaderAvailableTimestamp)) - : (this._recorder = x.NullLoaderEventRecorder.INSTANCE)), - this._recorder - ) - } - getLoaderEvents() { - return this.getRecorder().getEvents() - } - enqueueDefineAnonymousModule(h, a) { - if (this._currentAnonymousDefineCall !== null) - throw new Error('Can only have one anonymous define call per script file') - let w = null - this._config.isBuild() && (w = new Error('StackLocation').stack || null), - (this._currentAnonymousDefineCall = { stack: w, dependencies: h, callback: a }) - } - defineModule(h, a, w, e, b, f = new n(h)) { - let v = this._moduleIdProvider.getModuleId(h) - if (this._modules2[v]) { - this._config.isDuplicateMessageIgnoredFor(h) || - console.warn("Duplicate definition of module '" + h + "'") - return - } - let g = new R(v, h, this._normalizeDependencies(a, f), w, e, f) - ;(this._modules2[v] = g), - this._config.isBuild() && - ((this._buildInfoDefineStack[v] = b), - (this._buildInfoDependencies[v] = (g.dependencies || []).map((S) => - this._moduleIdProvider.getStrModuleId(S.id) - ))), - this._resolve(g) - } - _normalizeDependency(h, a) { - if (h === 'exports') return i.EXPORTS - if (h === 'module') return i.MODULE - if (h === 'require') return i.REQUIRE - let w = h.indexOf('!') - if (w >= 0) { - let e = a.resolveModule(h.substr(0, w)), - b = a.resolveModule(h.substr(w + 1)), - f = this._moduleIdProvider.getModuleId(e + '!' + b), - v = this._moduleIdProvider.getModuleId(e) - return new s(f, v, b) - } - return new i(this._moduleIdProvider.getModuleId(a.resolveModule(h))) - } - _normalizeDependencies(h, a) { - let w = [], - e = 0 - for (let b = 0, f = h.length; b < f; b++) w[e++] = this._normalizeDependency(h[b], a) - return w - } - _relativeRequire(h, a, w, e) { - if (typeof a == 'string') return this.synchronousRequire(a, h) - this.defineModule(x.Utilities.generateAnonymousModule(), a, w, e, null, h) - } - synchronousRequire(h, a = new n(h)) { - let w = this._normalizeDependency(h, a), - e = this._modules2[w.id] - if (!e) - throw new Error( - "Check dependency list! Synchronous require cannot resolve module '" + - h + - "'. This is the first mention of this module!" - ) - if (!e.isComplete()) - throw new Error( - "Check dependency list! Synchronous require cannot resolve module '" + - h + - "'. This module has not been resolved completely yet." - ) - if (e.error) throw e.error - return e.exports - } - configure(h, a) { - let w = this._config.shouldRecordStats() - a - ? (this._config = new x.Configuration(this._env, h)) - : (this._config = this._config.cloneAndMerge(h)), - this._config.shouldRecordStats() && !w && (this._recorder = null) - } - getConfig() { - return this._config - } - _onLoad(h) { - if (this._currentAnonymousDefineCall !== null) { - let a = this._currentAnonymousDefineCall - ;(this._currentAnonymousDefineCall = null), - this.defineModule( - this._moduleIdProvider.getStrModuleId(h), - a.dependencies, - a.callback, - null, - a.stack - ) - } - } - _createLoadError(h, a) { - let w = this._moduleIdProvider.getStrModuleId(h), - e = (this._inverseDependencies2[h] || []).map((f) => - this._moduleIdProvider.getStrModuleId(f) - ) - const b = x.ensureError(a) - return (b.phase = 'loading'), (b.moduleId = w), (b.neededBy = e), b - } - _onLoadError(h, a) { - const w = this._createLoadError(h, a) - this._modules2[h] || - (this._modules2[h] = new R( - h, - this._moduleIdProvider.getStrModuleId(h), - [], - () => {}, - null, - null - )) - let e = [] - for (let v = 0, g = this._moduleIdProvider.getMaxModuleId(); v < g; v++) e[v] = !1 - let b = !1, - f = [] - for (f.push(h), e[h] = !0; f.length > 0; ) { - let v = f.shift(), - g = this._modules2[v] - g && (b = g.onDependencyError(w) || b) - let S = this._inverseDependencies2[v] - if (S) - for (let E = 0, y = S.length; E < y; E++) { - let _ = S[E] - e[_] || (f.push(_), (e[_] = !0)) - } - } - b || this._config.onError(w) - } - _hasDependencyPath(h, a) { - let w = this._modules2[h] - if (!w) return !1 - let e = [] - for (let f = 0, v = this._moduleIdProvider.getMaxModuleId(); f < v; f++) e[f] = !1 - let b = [] - for (b.push(w), e[h] = !0; b.length > 0; ) { - let v = b.shift().dependencies - if (v) - for (let g = 0, S = v.length; g < S; g++) { - let E = v[g] - if (E.id === a) return !0 - let y = this._modules2[E.id] - y && !e[E.id] && ((e[E.id] = !0), b.push(y)) - } - } - return !1 - } - _findCyclePath(h, a, w) { - if (h === a || w === 50) return [h] - let e = this._modules2[h] - if (!e) return null - let b = e.dependencies - if (b) - for (let f = 0, v = b.length; f < v; f++) { - let g = this._findCyclePath(b[f].id, a, w + 1) - if (g !== null) return g.push(h), g - } - return null - } - _createRequire(h) { - let a = (w, e, b) => this._relativeRequire(h, w, e, b) - return ( - (a.toUrl = (w) => this._config.requireToUrl(h.resolveModule(w))), - (a.getStats = () => this.getLoaderEvents()), - (a.hasDependencyCycle = () => this._hasDependencyCycle), - (a.config = (w, e = !1) => { - this.configure(w, e) - }), - (a.__$__nodeRequire = x.global.nodeRequire), - a - ) - } - _loadModule(h) { - if (this._modules2[h] || this._knownModules2[h]) return - this._knownModules2[h] = !0 - let a = this._moduleIdProvider.getStrModuleId(h), - w = this._config.moduleIdToPaths(a), - e = /^@[^\/]+\/[^\/]+$/ - this._env.isNode && (a.indexOf('/') === -1 || e.test(a)) && w.push('node|' + a) - let b = -1, - f = (v) => { - if ((b++, b >= w.length)) this._onLoadError(h, v) - else { - let g = w[b], - S = this.getRecorder() - if (this._config.isBuild() && g === 'empty:') { - ;(this._buildInfoPath[h] = g), - this.defineModule(this._moduleIdProvider.getStrModuleId(h), [], null, null, null), - this._onLoad(h) - return - } - S.record(10, g), - this._scriptLoader.load( - this, - g, - () => { - this._config.isBuild() && (this._buildInfoPath[h] = g), - S.record(11, g), - this._onLoad(h) - }, - (E) => { - S.record(12, g), f(E) - } - ) - } - } - f(null) - } - _loadPluginDependency(h, a) { - if (this._modules2[a.id] || this._knownModules2[a.id]) return - this._knownModules2[a.id] = !0 - let w = (e) => { - this.defineModule(this._moduleIdProvider.getStrModuleId(a.id), [], e, null, null) - } - ;(w.error = (e) => { - this._config.onError(this._createLoadError(a.id, e)) - }), - h.load(a.pluginParam, this._createRequire(n.ROOT), w, this._config.getOptionsLiteral()) - } - _resolve(h) { - let a = h.dependencies - if (a) - for (let w = 0, e = a.length; w < e; w++) { - let b = a[w] - if (b === i.EXPORTS) { - ;(h.exportsPassedIn = !0), h.unresolvedDependenciesCount-- - continue - } - if (b === i.MODULE) { - h.unresolvedDependenciesCount-- - continue - } - if (b === i.REQUIRE) { - h.unresolvedDependenciesCount-- - continue - } - let f = this._modules2[b.id] - if (f && f.isComplete()) { - if (f.error) { - h.onDependencyError(f.error) - return - } - h.unresolvedDependenciesCount-- - continue - } - if (this._hasDependencyPath(b.id, h.id)) { - ;(this._hasDependencyCycle = !0), - console.warn( - "There is a dependency cycle between '" + - this._moduleIdProvider.getStrModuleId(b.id) + - "' and '" + - this._moduleIdProvider.getStrModuleId(h.id) + - "'. The cyclic path follows:" - ) - let v = this._findCyclePath(b.id, h.id, 0) || [] - v.reverse(), - v.push(b.id), - console.warn( - v.map((g) => this._moduleIdProvider.getStrModuleId(g)).join(` => -`) - ), - h.unresolvedDependenciesCount-- - continue - } - if ( - ((this._inverseDependencies2[b.id] = this._inverseDependencies2[b.id] || []), - this._inverseDependencies2[b.id].push(h.id), - b instanceof s) - ) { - let v = this._modules2[b.pluginId] - if (v && v.isComplete()) { - this._loadPluginDependency(v.exports, b) - continue - } - let g = this._inversePluginDependencies2.get(b.pluginId) - g || ((g = []), this._inversePluginDependencies2.set(b.pluginId, g)), - g.push(b), - this._loadModule(b.pluginId) - continue - } - this._loadModule(b.id) - } - h.unresolvedDependenciesCount === 0 && this._onModuleComplete(h) - } - _onModuleComplete(h) { - let a = this.getRecorder() - if (h.isComplete()) return - let w = h.dependencies, - e = [] - if (w) - for (let g = 0, S = w.length; g < S; g++) { - let E = w[g] - if (E === i.EXPORTS) { - e[g] = h.exports - continue - } - if (E === i.MODULE) { - e[g] = { id: h.strId, config: () => this._config.getConfigForModule(h.strId) } - continue - } - if (E === i.REQUIRE) { - e[g] = this._createRequire(h.moduleIdResolver) - continue - } - let y = this._modules2[E.id] - if (y) { - e[g] = y.exports - continue - } - e[g] = null - } - const b = (g) => - (this._inverseDependencies2[g] || []).map((S) => this._moduleIdProvider.getStrModuleId(S)) - h.complete(a, this._config, e, b) - let f = this._inverseDependencies2[h.id] - if (((this._inverseDependencies2[h.id] = null), f)) - for (let g = 0, S = f.length; g < S; g++) { - let E = f[g], - y = this._modules2[E] - y.unresolvedDependenciesCount--, - y.unresolvedDependenciesCount === 0 && this._onModuleComplete(y) - } - let v = this._inversePluginDependencies2.get(h.id) - if (v) { - this._inversePluginDependencies2.delete(h.id) - for (let g = 0, S = v.length; g < S; g++) this._loadPluginDependency(h.exports, v[g]) - } - } - } - x.ModuleManager = p - })(ae || (ae = {})) - var Q, ae - ;(function (x) { - const n = new x.Environment() - let R = null - const D = function (L, h, a) { - typeof L != 'string' && ((a = h), (h = L), (L = null)), - (typeof h != 'object' || !Array.isArray(h)) && ((a = h), (h = null)), - h || (h = ['require', 'exports', 'module']), - L ? R.defineModule(L, h, a, null, null) : R.enqueueDefineAnonymousModule(h, a) - } - D.amd = { jQuery: !0 } - const i = function (L, h = !1) { - R.configure(L, h) - }, - s = function () { - if (arguments.length === 1) { - if (arguments[0] instanceof Object && !Array.isArray(arguments[0])) { - i(arguments[0]) - return - } - if (typeof arguments[0] == 'string') return R.synchronousRequire(arguments[0]) - } - if ((arguments.length === 2 || arguments.length === 3) && Array.isArray(arguments[0])) { - R.defineModule( - x.Utilities.generateAnonymousModule(), - arguments[0], - arguments[1], - arguments[2], - null - ) - return - } - throw new Error('Unrecognized require call') - } - ;(s.config = i), - (s.getConfig = function () { - return R.getConfig().getOptionsLiteral() - }), - (s.reset = function () { - R = R.reset() - }), - (s.getBuildInfo = function () { - return R.getBuildInfo() - }), - (s.getStats = function () { - return R.getLoaderEvents() - }), - (s.define = D) - function p() { - if (typeof x.global.require < 'u' || typeof require < 'u') { - const L = x.global.require || require - if (typeof L == 'function' && typeof L.resolve == 'function') { - const h = x.ensureRecordedNodeRequire(R.getRecorder(), L) - ;(x.global.nodeRequire = h), (s.nodeRequire = h), (s.__$__nodeRequire = h) - } - } - n.isNode && !n.isElectronRenderer && !n.isElectronNodeIntegrationWebWorker - ? (module.exports = s) - : (n.isElectronRenderer || (x.global.define = D), (x.global.require = s)) - } - ;(x.init = p), - (typeof x.global.define != 'function' || !x.global.define.amd) && - ((R = new x.ModuleManager( - n, - x.createScriptLoader(n), - D, - s, - x.Utilities.getHighPerformanceTimestamp() - )), - typeof x.global.require < 'u' && - typeof x.global.require != 'function' && - s.config(x.global.require), - (Q = function () { - return D.apply(null, arguments) - }), - (Q.amd = D.amd), - typeof doNotInitLoader > 'u' && p()) - })(ae || (ae = {})) - var be = - (this && this.__awaiter) || - function (x, n, R, D) { - function i(s) { - return s instanceof R - ? s - : new R(function (p) { - p(s) - }) - } - return new (R || (R = Promise))(function (s, p) { - function L(w) { - try { - a(D.next(w)) - } catch (e) { - p(e) - } - } - function h(w) { - try { - a(D.throw(w)) - } catch (e) { - p(e) - } - } - function a(w) { - w.done ? s(w.value) : i(w.value).then(L, h) - } - a((D = D.apply(x, n || [])).next()) - }) - } - Q(Y[25], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.load = - n.create = - n.setPseudoTranslation = - n.getConfiguredDefaultLocale = - n.localize = - void 0) - let R = - typeof document < 'u' && - document.location && - document.location.hash.indexOf('pseudo=true') >= 0 - const D = 'i-default' - function i(v, g) { - let S - return ( - g.length === 0 - ? (S = v) - : (S = v.replace(/\{(\d+)\}/g, (E, y) => { - const _ = y[0], - d = g[_] - let C = E - return ( - typeof d == 'string' - ? (C = d) - : (typeof d == 'number' || typeof d == 'boolean' || d === void 0 || d === null) && - (C = String(d)), - C - ) - })), - R && (S = '\uFF3B' + S.replace(/[aouei]/g, '$&$&') + '\uFF3D'), - S - ) - } - function s(v, g) { - let S = v[g] - return S || ((S = v['*']), S) ? S : null - } - function p(v) { - return v.charAt(v.length - 1) === '/' ? v : v + '/' - } - function L(v, g, S) { - return be(this, void 0, void 0, function* () { - const E = p(v) + p(g) + 'vscode/' + p(S), - y = yield fetch(E) - if (y.ok) return yield y.json() - throw new Error(`${y.status} - ${y.statusText}`) - }) - } - function h(v) { - return function (g, S) { - const E = Array.prototype.slice.call(arguments, 2) - return i(v[g], E) - } - } - function a(v, g, ...S) { - return i(g, S) - } - n.localize = a - function w(v) {} - n.getConfiguredDefaultLocale = w - function e(v) { - R = v - } - n.setPseudoTranslation = e - function b(v, g) { - var S - return { - localize: h(g[v]), - getConfiguredDefaultLocale: - (S = g.getConfiguredDefaultLocale) !== null && S !== void 0 ? S : (E) => {}, - } - } - n.create = b - function f(v, g, S, E) { - var y - const _ = (y = E['vs/nls']) !== null && y !== void 0 ? y : {} - if (!v || v.length === 0) - return S({ - localize: a, - getConfiguredDefaultLocale: () => { - var o - return (o = _.availableLanguages) === null || o === void 0 ? void 0 : o['*'] - }, - }) - const d = _.availableLanguages ? s(_.availableLanguages, v) : null, - C = d === null || d === D - let r = '.nls' - C || (r = r + '.' + d) - const u = (o) => { - Array.isArray(o) ? (o.localize = h(o)) : (o.localize = h(o[v])), - (o.getConfiguredDefaultLocale = () => { - var c - return (c = _.availableLanguages) === null || c === void 0 ? void 0 : c['*'] - }), - S(o) - } - typeof _.loadBundle == 'function' - ? _.loadBundle(v, d, (o, c) => { - o ? g([v + '.nls'], u) : u(c) - }) - : _.translationServiceUrl && !C - ? be(this, void 0, void 0, function* () { - var o - try { - const c = yield L(_.translationServiceUrl, d, v) - return u(c) - } catch (c) { - if (!d.includes('-')) return console.error(c), g([v + '.nls'], u) - try { - const l = d.split('-')[0], - m = yield L(_.translationServiceUrl, l, v) - return ( - ((o = _.availableLanguages) !== null && o !== void 0) || - (_.availableLanguages = {}), - (_.availableLanguages['*'] = l), - u(m) - ) - } catch (l) { - return console.error(l), g([v + '.nls'], u) - } - } - }) - : g([v + r], u, (o) => { - if (r === '.nls') { - console.error('Failed trying to load default language strings', o) - return - } - console.error( - `Failed to load message bundle for language ${d}. Falling back to the default language:`, - o - ), - g([v + '.nls'], u) - }) - } - n.load = f - }), - (function () { - var x, n - const R = globalThis.MonacoEnvironment, - D = R && R.baseUrl ? R.baseUrl : '../../../', - i = - typeof ((x = self.trustedTypes) === null || x === void 0 ? void 0 : x.createPolicy) == - 'function' - ? (n = self.trustedTypes) === null || n === void 0 - ? void 0 - : n.createPolicy('amdLoader', { - createScriptURL: (e) => e, - createScript: (e, ...b) => { - const f = b.slice(0, -1).join(','), - v = b.pop().toString() - return `(function anonymous(${f}) { ${v} -})` - }, - }) - : void 0 - function s() { - try { - return ( - (i ? globalThis.eval(i.createScript('', 'true')) : new Function('true')).call( - globalThis - ), - !0 - ) - } catch { - return !1 - } - } - function p() { - return new Promise((e, b) => { - if (typeof globalThis.define == 'function' && globalThis.define.amd) return e() - const f = D + 'vs/loader.js' - if ( - !( - /^((http:)|(https:)|(file:))/.test(f) && - f.substring(0, globalThis.origin.length) !== globalThis.origin - ) && - s() - ) { - fetch(f) - .then((g) => { - if (g.status !== 200) throw new Error(g.statusText) - return g.text() - }) - .then((g) => { - ;(g = `${g} -//# sourceURL=${f}`), - (i ? globalThis.eval(i.createScript('', g)) : new Function(g)).call(globalThis), - e() - }) - .then(void 0, b) - return - } - i ? importScripts(i.createScriptURL(f)) : importScripts(f), e() - }) - } - function L() { - require.config({ - baseUrl: D, - catchError: !0, - trustedTypesPolicy: i, - amdModulesPattern: /^vs\//, - }) - } - function h(e) { - p().then(() => { - L(), - require([e], function (b) { - setTimeout(function () { - const f = b.create((v, g) => { - globalThis.postMessage(v, g) - }, null) - for (globalThis.onmessage = (v) => f.onmessage(v.data, v.ports); w.length > 0; ) { - const v = w.shift() - f.onmessage(v.data, v.ports) - } - }, 0) - }) - }) - } - typeof globalThis.define == 'function' && globalThis.define.amd && L() - let a = !0 - const w = [] - globalThis.onmessage = (e) => { - if (!a) { - w.push(e) - return - } - ;(a = !1), h(e.data) - } - })(), - Q(Y[26], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.CallbackIterable = - n.ArrayQueue = - n.findMinBy = - n.findLastMaxBy = - n.findMaxBy = - n.numberComparator = - n.compareBy = - n.CompareResult = - n.splice = - n.insertInto = - n.asArray = - n.pushMany = - n.pushToEnd = - n.pushToStart = - n.arrayInsert = - n.range = - n.firstOrDefault = - n.lastIndex = - n.findLast = - n.distinct = - n.isNonEmptyArray = - n.isFalsyOrEmpty = - n.coalesceInPlace = - n.coalesce = - n.groupBy = - n.quickSelect = - n.findFirstInSorted = - n.binarySearch2 = - n.binarySearch = - n.removeFastWithoutKeepingOrder = - n.equals = - n.tail2 = - n.tail = - void 0) - function R(F, U = 0) { - return F[F.length - (1 + U)] - } - n.tail = R - function D(F) { - if (F.length === 0) throw new Error('Invalid tail call') - return [F.slice(0, F.length - 1), F[F.length - 1]] - } - n.tail2 = D - function i(F, U, T = (W, t) => W === t) { - if (F === U) return !0 - if (!F || !U || F.length !== U.length) return !1 - for (let W = 0, t = F.length; W < t; W++) if (!T(F[W], U[W])) return !1 - return !0 - } - n.equals = i - function s(F, U) { - const T = F.length - 1 - U < T && (F[U] = F[T]), F.pop() - } - n.removeFastWithoutKeepingOrder = s - function p(F, U, T) { - return L(F.length, (W) => T(F[W], U)) - } - n.binarySearch = p - function L(F, U) { - let T = 0, - W = F - 1 - for (; T <= W; ) { - const t = ((T + W) / 2) | 0, - te = U(t) - if (te < 0) T = t + 1 - else if (te > 0) W = t - 1 - else return t - } - return -(T + 1) - } - n.binarySearch2 = L - function h(F, U) { - let T = 0, - W = F.length - if (W === 0) return 0 - for (; T < W; ) { - const t = Math.floor((T + W) / 2) - U(F[t]) ? (W = t) : (T = t + 1) - } - return T - } - n.findFirstInSorted = h - function a(F, U, T) { - if (((F = F | 0), F >= U.length)) throw new TypeError('invalid index') - const W = U[Math.floor(U.length * Math.random())], - t = [], - te = [], - ie = [] - for (const ue of U) { - const de = T(ue, W) - de < 0 ? t.push(ue) : de > 0 ? te.push(ue) : ie.push(ue) - } - return F < t.length - ? a(F, t, T) - : F < t.length + ie.length - ? ie[0] - : a(F - (t.length + ie.length), te, T) - } - n.quickSelect = a - function w(F, U) { - const T = [] - let W - for (const t of F.slice(0).sort(U)) - !W || U(W[0], t) !== 0 ? ((W = [t]), T.push(W)) : W.push(t) - return T - } - n.groupBy = w - function e(F) { - return F.filter((U) => !!U) - } - n.coalesce = e - function b(F) { - let U = 0 - for (let T = 0; T < F.length; T++) F[T] && ((F[U] = F[T]), (U += 1)) - F.length = U - } - n.coalesceInPlace = b - function f(F) { - return !Array.isArray(F) || F.length === 0 - } - n.isFalsyOrEmpty = f - function v(F) { - return Array.isArray(F) && F.length > 0 - } - n.isNonEmptyArray = v - function g(F, U = (T) => T) { - const T = new Set() - return F.filter((W) => { - const t = U(W) - return T.has(t) ? !1 : (T.add(t), !0) - }) - } - n.distinct = g - function S(F, U) { - const T = E(F, U) - if (T !== -1) return F[T] - } - n.findLast = S - function E(F, U) { - for (let T = F.length - 1; T >= 0; T--) { - const W = F[T] - if (U(W)) return T - } - return -1 - } - n.lastIndex = E - function y(F, U) { - return F.length > 0 ? F[0] : U - } - n.firstOrDefault = y - function _(F, U) { - let T = typeof U == 'number' ? F : 0 - typeof U == 'number' ? (T = F) : ((T = 0), (U = F)) - const W = [] - if (T <= U) for (let t = T; t < U; t++) W.push(t) - else for (let t = T; t > U; t--) W.push(t) - return W - } - n.range = _ - function d(F, U, T) { - const W = F.slice(0, U), - t = F.slice(U) - return W.concat(T, t) - } - n.arrayInsert = d - function C(F, U) { - const T = F.indexOf(U) - T > -1 && (F.splice(T, 1), F.unshift(U)) - } - n.pushToStart = C - function r(F, U) { - const T = F.indexOf(U) - T > -1 && (F.splice(T, 1), F.push(U)) - } - n.pushToEnd = r - function u(F, U) { - for (const T of U) F.push(T) - } - n.pushMany = u - function o(F) { - return Array.isArray(F) ? F : [F] - } - n.asArray = o - function c(F, U, T) { - const W = m(F, U), - t = F.length, - te = T.length - F.length = t + te - for (let ie = t - 1; ie >= W; ie--) F[ie + te] = F[ie] - for (let ie = 0; ie < te; ie++) F[ie + W] = T[ie] - } - n.insertInto = c - function l(F, U, T, W) { - const t = m(F, U), - te = F.splice(t, T) - return c(F, t, W), te - } - n.splice = l - function m(F, U) { - return U < 0 ? Math.max(U + F.length, 0) : Math.min(U, F.length) - } - var N - ;(function (F) { - function U(t) { - return t < 0 - } - F.isLessThan = U - function T(t) { - return t > 0 - } - F.isGreaterThan = T - function W(t) { - return t === 0 - } - ;(F.isNeitherLessOrGreaterThan = W), - (F.greaterThan = 1), - (F.lessThan = -1), - (F.neitherLessOrGreaterThan = 0) - })((N = n.CompareResult || (n.CompareResult = {}))) - function A(F, U) { - return (T, W) => U(F(T), F(W)) - } - n.compareBy = A - const M = (F, U) => F - U - n.numberComparator = M - function k(F, U) { - if (F.length === 0) return - let T = F[0] - for (let W = 1; W < F.length; W++) { - const t = F[W] - U(t, T) > 0 && (T = t) - } - return T - } - n.findMaxBy = k - function q(F, U) { - if (F.length === 0) return - let T = F[0] - for (let W = 1; W < F.length; W++) { - const t = F[W] - U(t, T) >= 0 && (T = t) - } - return T - } - n.findLastMaxBy = q - function I(F, U) { - return k(F, (T, W) => -U(T, W)) - } - n.findMinBy = I - class B { - constructor(U) { - ;(this.items = U), (this.firstIdx = 0), (this.lastIdx = this.items.length - 1) - } - get length() { - return this.lastIdx - this.firstIdx + 1 - } - takeWhile(U) { - let T = this.firstIdx - for (; T < this.items.length && U(this.items[T]); ) T++ - const W = T === this.firstIdx ? null : this.items.slice(this.firstIdx, T) - return (this.firstIdx = T), W - } - takeFromEndWhile(U) { - let T = this.lastIdx - for (; T >= 0 && U(this.items[T]); ) T-- - const W = T === this.lastIdx ? null : this.items.slice(T + 1, this.lastIdx + 1) - return (this.lastIdx = T), W - } - peek() { - if (this.length !== 0) return this.items[this.firstIdx] - } - dequeue() { - const U = this.items[this.firstIdx] - return this.firstIdx++, U - } - takeCount(U) { - const T = this.items.slice(this.firstIdx, this.firstIdx + U) - return (this.firstIdx += U), T - } - } - n.ArrayQueue = B - class H { - constructor(U) { - this.iterate = U - } - toArray() { - const U = [] - return this.iterate((T) => (U.push(T), !0)), U - } - filter(U) { - return new H((T) => this.iterate((W) => (U(W) ? T(W) : !0))) - } - map(U) { - return new H((T) => this.iterate((W) => T(U(W)))) - } - findLast(U) { - let T - return this.iterate((W) => (U(W) && (T = W), !0)), T - } - findLastMaxBy(U) { - let T, - W = !0 - return ( - this.iterate((t) => ((W || N.isGreaterThan(U(t, T))) && ((W = !1), (T = t)), !0)), T - ) - } - } - ;(H.empty = new H((F) => {})), (n.CallbackIterable = H) - }), - Q(Y[27], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.CachedFunction = n.LRUCachedFunction = void 0) - class R { - constructor(s) { - ;(this.fn = s), (this.lastCache = void 0), (this.lastArgKey = void 0) - } - get(s) { - const p = JSON.stringify(s) - return ( - this.lastArgKey !== p && ((this.lastArgKey = p), (this.lastCache = this.fn(s))), - this.lastCache - ) - } - } - n.LRUCachedFunction = R - class D { - get cachedValues() { - return this._map - } - constructor(s) { - ;(this.fn = s), (this._map = new Map()) - } - get(s) { - if (this._map.has(s)) return this._map.get(s) - const p = this.fn(s) - return this._map.set(s, p), p - } - } - n.CachedFunction = D - }), - Q(Y[28], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.DiffChange = void 0) - class R { - constructor(i, s, p, L) { - ;(this.originalStart = i), - (this.originalLength = s), - (this.modifiedStart = p), - (this.modifiedLength = L) - } - getOriginalEnd() { - return this.originalStart + this.originalLength - } - getModifiedEnd() { - return this.modifiedStart + this.modifiedLength - } - } - n.DiffChange = R - }), - Q(Y[4], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.BugIndicatingError = - n.ErrorNoTelemetry = - n.NotSupportedError = - n.illegalState = - n.illegalArgument = - n.canceled = - n.CancellationError = - n.isCancellationError = - n.transformErrorForSerialization = - n.onUnexpectedExternalError = - n.onUnexpectedError = - n.errorHandler = - n.ErrorHandler = - void 0) - class R { - constructor() { - ;(this.listeners = []), - (this.unexpectedErrorHandler = function (S) { - setTimeout(() => { - throw S.stack - ? f.isErrorNoTelemetry(S) - ? new f( - S.message + - ` - -` + - S.stack - ) - : new Error( - S.message + - ` - -` + - S.stack - ) - : S - }, 0) - }) - } - emit(S) { - this.listeners.forEach((E) => { - E(S) - }) - } - onUnexpectedError(S) { - this.unexpectedErrorHandler(S), this.emit(S) - } - onUnexpectedExternalError(S) { - this.unexpectedErrorHandler(S) - } - } - ;(n.ErrorHandler = R), (n.errorHandler = new R()) - function D(g) { - L(g) || n.errorHandler.onUnexpectedError(g) - } - n.onUnexpectedError = D - function i(g) { - L(g) || n.errorHandler.onUnexpectedExternalError(g) - } - n.onUnexpectedExternalError = i - function s(g) { - if (g instanceof Error) { - const { name: S, message: E } = g, - y = g.stacktrace || g.stack - return { - $isError: !0, - name: S, - message: E, - stack: y, - noTelemetry: f.isErrorNoTelemetry(g), - } - } - return g - } - n.transformErrorForSerialization = s - const p = 'Canceled' - function L(g) { - return g instanceof h ? !0 : g instanceof Error && g.name === p && g.message === p - } - n.isCancellationError = L - class h extends Error { - constructor() { - super(p), (this.name = this.message) - } - } - n.CancellationError = h - function a() { - const g = new Error(p) - return (g.name = g.message), g - } - n.canceled = a - function w(g) { - return g ? new Error(`Illegal argument: ${g}`) : new Error('Illegal argument') - } - n.illegalArgument = w - function e(g) { - return g ? new Error(`Illegal state: ${g}`) : new Error('Illegal state') - } - n.illegalState = e - class b extends Error { - constructor(S) { - super('NotSupported'), S && (this.message = S) - } - } - n.NotSupportedError = b - class f extends Error { - constructor(S) { - super(S), (this.name = 'CodeExpectedError') - } - static fromError(S) { - if (S instanceof f) return S - const E = new f() - return (E.message = S.message), (E.stack = S.stack), E - } - static isErrorNoTelemetry(S) { - return S.name === 'CodeExpectedError' - } - } - n.ErrorNoTelemetry = f - class v extends Error { - constructor(S) { - super(S || 'An unexpected bug occurred.'), Object.setPrototypeOf(this, v.prototype) - debugger - } - } - n.BugIndicatingError = v - }), - Q(Y[10], X([0, 1, 4]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.checkAdjacentItems = n.assertFn = n.assertNever = n.ok = void 0) - function D(L, h) { - if (!L) throw new Error(h ? `Assertion failed (${h})` : 'Assertion Failed') - } - n.ok = D - function i(L, h = 'Unreachable') { - throw new Error(h) - } - n.assertNever = i - function s(L) { - if (!L()) { - debugger - L(), (0, R.onUnexpectedError)(new R.BugIndicatingError('Assertion Failed')) - } - } - n.assertFn = s - function p(L, h) { - let a = 0 - for (; a < L.length - 1; ) { - const w = L[a], - e = L[a + 1] - if (!h(w, e)) return !1 - a++ - } - return !0 - } - n.checkAdjacentItems = p - }), - Q(Y[14], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.once = void 0) - function R(D) { - const i = this - let s = !1, - p - return function () { - return s || ((s = !0), (p = D.apply(i, arguments))), p - } - } - n.once = R - }), - Q(Y[15], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.Iterable = void 0) - var R - ;(function (D) { - function i(d) { - return d && typeof d == 'object' && typeof d[Symbol.iterator] == 'function' - } - D.is = i - const s = Object.freeze([]) - function p() { - return s - } - D.empty = p - function* L(d) { - yield d - } - D.single = L - function h(d) { - return i(d) ? d : L(d) - } - D.wrap = h - function a(d) { - return d || s - } - D.from = a - function w(d) { - return !d || d[Symbol.iterator]().next().done === !0 - } - D.isEmpty = w - function e(d) { - return d[Symbol.iterator]().next().value - } - D.first = e - function b(d, C) { - for (const r of d) if (C(r)) return !0 - return !1 - } - D.some = b - function f(d, C) { - for (const r of d) if (C(r)) return r - } - D.find = f - function* v(d, C) { - for (const r of d) C(r) && (yield r) - } - D.filter = v - function* g(d, C) { - let r = 0 - for (const u of d) yield C(u, r++) - } - D.map = g - function* S(...d) { - for (const C of d) for (const r of C) yield r - } - D.concat = S - function E(d, C, r) { - let u = r - for (const o of d) u = C(u, o) - return u - } - D.reduce = E - function* y(d, C, r = d.length) { - for ( - C < 0 && (C += d.length), r < 0 ? (r += d.length) : r > d.length && (r = d.length); - C < r; - C++ - ) - yield d[C] - } - D.slice = y - function _(d, C = Number.POSITIVE_INFINITY) { - const r = [] - if (C === 0) return [r, d] - const u = d[Symbol.iterator]() - for (let o = 0; o < C; o++) { - const c = u.next() - if (c.done) return [r, D.empty()] - r.push(c.value) - } - return [ - r, - { - [Symbol.iterator]() { - return u - }, - }, - ] - } - D.consume = _ - })((R = n.Iterable || (n.Iterable = {}))) - }), - Q(Y[29], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.KeyChord = - n.KeyCodeUtils = - n.IMMUTABLE_KEY_CODE_TO_CODE = - n.IMMUTABLE_CODE_TO_KEY_CODE = - n.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE = - n.EVENT_KEY_CODE_MAP = - void 0) - class R { - constructor() { - ;(this._keyCodeToStr = []), (this._strToKeyCode = Object.create(null)) - } - define(b, f) { - ;(this._keyCodeToStr[b] = f), (this._strToKeyCode[f.toLowerCase()] = b) - } - keyCodeToStr(b) { - return this._keyCodeToStr[b] - } - strToKeyCode(b) { - return this._strToKeyCode[b.toLowerCase()] || 0 - } - } - const D = new R(), - i = new R(), - s = new R() - ;(n.EVENT_KEY_CODE_MAP = new Array(230)), (n.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE = {}) - const p = [], - L = Object.create(null), - h = Object.create(null) - ;(n.IMMUTABLE_CODE_TO_KEY_CODE = []), (n.IMMUTABLE_KEY_CODE_TO_CODE = []) - for (let e = 0; e <= 193; e++) n.IMMUTABLE_CODE_TO_KEY_CODE[e] = -1 - for (let e = 0; e <= 127; e++) n.IMMUTABLE_KEY_CODE_TO_CODE[e] = -1 - ;(function () { - const e = '', - b = [ - [0, 1, 0, 'None', 0, 'unknown', 0, 'VK_UNKNOWN', e, e], - [0, 1, 1, 'Hyper', 0, e, 0, e, e, e], - [0, 1, 2, 'Super', 0, e, 0, e, e, e], - [0, 1, 3, 'Fn', 0, e, 0, e, e, e], - [0, 1, 4, 'FnLock', 0, e, 0, e, e, e], - [0, 1, 5, 'Suspend', 0, e, 0, e, e, e], - [0, 1, 6, 'Resume', 0, e, 0, e, e, e], - [0, 1, 7, 'Turbo', 0, e, 0, e, e, e], - [0, 1, 8, 'Sleep', 0, e, 0, 'VK_SLEEP', e, e], - [0, 1, 9, 'WakeUp', 0, e, 0, e, e, e], - [31, 0, 10, 'KeyA', 31, 'A', 65, 'VK_A', e, e], - [32, 0, 11, 'KeyB', 32, 'B', 66, 'VK_B', e, e], - [33, 0, 12, 'KeyC', 33, 'C', 67, 'VK_C', e, e], - [34, 0, 13, 'KeyD', 34, 'D', 68, 'VK_D', e, e], - [35, 0, 14, 'KeyE', 35, 'E', 69, 'VK_E', e, e], - [36, 0, 15, 'KeyF', 36, 'F', 70, 'VK_F', e, e], - [37, 0, 16, 'KeyG', 37, 'G', 71, 'VK_G', e, e], - [38, 0, 17, 'KeyH', 38, 'H', 72, 'VK_H', e, e], - [39, 0, 18, 'KeyI', 39, 'I', 73, 'VK_I', e, e], - [40, 0, 19, 'KeyJ', 40, 'J', 74, 'VK_J', e, e], - [41, 0, 20, 'KeyK', 41, 'K', 75, 'VK_K', e, e], - [42, 0, 21, 'KeyL', 42, 'L', 76, 'VK_L', e, e], - [43, 0, 22, 'KeyM', 43, 'M', 77, 'VK_M', e, e], - [44, 0, 23, 'KeyN', 44, 'N', 78, 'VK_N', e, e], - [45, 0, 24, 'KeyO', 45, 'O', 79, 'VK_O', e, e], - [46, 0, 25, 'KeyP', 46, 'P', 80, 'VK_P', e, e], - [47, 0, 26, 'KeyQ', 47, 'Q', 81, 'VK_Q', e, e], - [48, 0, 27, 'KeyR', 48, 'R', 82, 'VK_R', e, e], - [49, 0, 28, 'KeyS', 49, 'S', 83, 'VK_S', e, e], - [50, 0, 29, 'KeyT', 50, 'T', 84, 'VK_T', e, e], - [51, 0, 30, 'KeyU', 51, 'U', 85, 'VK_U', e, e], - [52, 0, 31, 'KeyV', 52, 'V', 86, 'VK_V', e, e], - [53, 0, 32, 'KeyW', 53, 'W', 87, 'VK_W', e, e], - [54, 0, 33, 'KeyX', 54, 'X', 88, 'VK_X', e, e], - [55, 0, 34, 'KeyY', 55, 'Y', 89, 'VK_Y', e, e], - [56, 0, 35, 'KeyZ', 56, 'Z', 90, 'VK_Z', e, e], - [22, 0, 36, 'Digit1', 22, '1', 49, 'VK_1', e, e], - [23, 0, 37, 'Digit2', 23, '2', 50, 'VK_2', e, e], - [24, 0, 38, 'Digit3', 24, '3', 51, 'VK_3', e, e], - [25, 0, 39, 'Digit4', 25, '4', 52, 'VK_4', e, e], - [26, 0, 40, 'Digit5', 26, '5', 53, 'VK_5', e, e], - [27, 0, 41, 'Digit6', 27, '6', 54, 'VK_6', e, e], - [28, 0, 42, 'Digit7', 28, '7', 55, 'VK_7', e, e], - [29, 0, 43, 'Digit8', 29, '8', 56, 'VK_8', e, e], - [30, 0, 44, 'Digit9', 30, '9', 57, 'VK_9', e, e], - [21, 0, 45, 'Digit0', 21, '0', 48, 'VK_0', e, e], - [3, 1, 46, 'Enter', 3, 'Enter', 13, 'VK_RETURN', e, e], - [9, 1, 47, 'Escape', 9, 'Escape', 27, 'VK_ESCAPE', e, e], - [1, 1, 48, 'Backspace', 1, 'Backspace', 8, 'VK_BACK', e, e], - [2, 1, 49, 'Tab', 2, 'Tab', 9, 'VK_TAB', e, e], - [10, 1, 50, 'Space', 10, 'Space', 32, 'VK_SPACE', e, e], - [83, 0, 51, 'Minus', 83, '-', 189, 'VK_OEM_MINUS', '-', 'OEM_MINUS'], - [81, 0, 52, 'Equal', 81, '=', 187, 'VK_OEM_PLUS', '=', 'OEM_PLUS'], - [87, 0, 53, 'BracketLeft', 87, '[', 219, 'VK_OEM_4', '[', 'OEM_4'], - [89, 0, 54, 'BracketRight', 89, ']', 221, 'VK_OEM_6', ']', 'OEM_6'], - [88, 0, 55, 'Backslash', 88, '\\', 220, 'VK_OEM_5', '\\', 'OEM_5'], - [0, 0, 56, 'IntlHash', 0, e, 0, e, e, e], - [80, 0, 57, 'Semicolon', 80, ';', 186, 'VK_OEM_1', ';', 'OEM_1'], - [90, 0, 58, 'Quote', 90, "'", 222, 'VK_OEM_7', "'", 'OEM_7'], - [86, 0, 59, 'Backquote', 86, '`', 192, 'VK_OEM_3', '`', 'OEM_3'], - [82, 0, 60, 'Comma', 82, ',', 188, 'VK_OEM_COMMA', ',', 'OEM_COMMA'], - [84, 0, 61, 'Period', 84, '.', 190, 'VK_OEM_PERIOD', '.', 'OEM_PERIOD'], - [85, 0, 62, 'Slash', 85, '/', 191, 'VK_OEM_2', '/', 'OEM_2'], - [8, 1, 63, 'CapsLock', 8, 'CapsLock', 20, 'VK_CAPITAL', e, e], - [59, 1, 64, 'F1', 59, 'F1', 112, 'VK_F1', e, e], - [60, 1, 65, 'F2', 60, 'F2', 113, 'VK_F2', e, e], - [61, 1, 66, 'F3', 61, 'F3', 114, 'VK_F3', e, e], - [62, 1, 67, 'F4', 62, 'F4', 115, 'VK_F4', e, e], - [63, 1, 68, 'F5', 63, 'F5', 116, 'VK_F5', e, e], - [64, 1, 69, 'F6', 64, 'F6', 117, 'VK_F6', e, e], - [65, 1, 70, 'F7', 65, 'F7', 118, 'VK_F7', e, e], - [66, 1, 71, 'F8', 66, 'F8', 119, 'VK_F8', e, e], - [67, 1, 72, 'F9', 67, 'F9', 120, 'VK_F9', e, e], - [68, 1, 73, 'F10', 68, 'F10', 121, 'VK_F10', e, e], - [69, 1, 74, 'F11', 69, 'F11', 122, 'VK_F11', e, e], - [70, 1, 75, 'F12', 70, 'F12', 123, 'VK_F12', e, e], - [0, 1, 76, 'PrintScreen', 0, e, 0, e, e, e], - [79, 1, 77, 'ScrollLock', 79, 'ScrollLock', 145, 'VK_SCROLL', e, e], - [7, 1, 78, 'Pause', 7, 'PauseBreak', 19, 'VK_PAUSE', e, e], - [19, 1, 79, 'Insert', 19, 'Insert', 45, 'VK_INSERT', e, e], - [14, 1, 80, 'Home', 14, 'Home', 36, 'VK_HOME', e, e], - [11, 1, 81, 'PageUp', 11, 'PageUp', 33, 'VK_PRIOR', e, e], - [20, 1, 82, 'Delete', 20, 'Delete', 46, 'VK_DELETE', e, e], - [13, 1, 83, 'End', 13, 'End', 35, 'VK_END', e, e], - [12, 1, 84, 'PageDown', 12, 'PageDown', 34, 'VK_NEXT', e, e], - [17, 1, 85, 'ArrowRight', 17, 'RightArrow', 39, 'VK_RIGHT', 'Right', e], - [15, 1, 86, 'ArrowLeft', 15, 'LeftArrow', 37, 'VK_LEFT', 'Left', e], - [18, 1, 87, 'ArrowDown', 18, 'DownArrow', 40, 'VK_DOWN', 'Down', e], - [16, 1, 88, 'ArrowUp', 16, 'UpArrow', 38, 'VK_UP', 'Up', e], - [78, 1, 89, 'NumLock', 78, 'NumLock', 144, 'VK_NUMLOCK', e, e], - [108, 1, 90, 'NumpadDivide', 108, 'NumPad_Divide', 111, 'VK_DIVIDE', e, e], - [103, 1, 91, 'NumpadMultiply', 103, 'NumPad_Multiply', 106, 'VK_MULTIPLY', e, e], - [106, 1, 92, 'NumpadSubtract', 106, 'NumPad_Subtract', 109, 'VK_SUBTRACT', e, e], - [104, 1, 93, 'NumpadAdd', 104, 'NumPad_Add', 107, 'VK_ADD', e, e], - [3, 1, 94, 'NumpadEnter', 3, e, 0, e, e, e], - [94, 1, 95, 'Numpad1', 94, 'NumPad1', 97, 'VK_NUMPAD1', e, e], - [95, 1, 96, 'Numpad2', 95, 'NumPad2', 98, 'VK_NUMPAD2', e, e], - [96, 1, 97, 'Numpad3', 96, 'NumPad3', 99, 'VK_NUMPAD3', e, e], - [97, 1, 98, 'Numpad4', 97, 'NumPad4', 100, 'VK_NUMPAD4', e, e], - [98, 1, 99, 'Numpad5', 98, 'NumPad5', 101, 'VK_NUMPAD5', e, e], - [99, 1, 100, 'Numpad6', 99, 'NumPad6', 102, 'VK_NUMPAD6', e, e], - [100, 1, 101, 'Numpad7', 100, 'NumPad7', 103, 'VK_NUMPAD7', e, e], - [101, 1, 102, 'Numpad8', 101, 'NumPad8', 104, 'VK_NUMPAD8', e, e], - [102, 1, 103, 'Numpad9', 102, 'NumPad9', 105, 'VK_NUMPAD9', e, e], - [93, 1, 104, 'Numpad0', 93, 'NumPad0', 96, 'VK_NUMPAD0', e, e], - [107, 1, 105, 'NumpadDecimal', 107, 'NumPad_Decimal', 110, 'VK_DECIMAL', e, e], - [92, 0, 106, 'IntlBackslash', 92, 'OEM_102', 226, 'VK_OEM_102', e, e], - [58, 1, 107, 'ContextMenu', 58, 'ContextMenu', 93, e, e, e], - [0, 1, 108, 'Power', 0, e, 0, e, e, e], - [0, 1, 109, 'NumpadEqual', 0, e, 0, e, e, e], - [71, 1, 110, 'F13', 71, 'F13', 124, 'VK_F13', e, e], - [72, 1, 111, 'F14', 72, 'F14', 125, 'VK_F14', e, e], - [73, 1, 112, 'F15', 73, 'F15', 126, 'VK_F15', e, e], - [74, 1, 113, 'F16', 74, 'F16', 127, 'VK_F16', e, e], - [75, 1, 114, 'F17', 75, 'F17', 128, 'VK_F17', e, e], - [76, 1, 115, 'F18', 76, 'F18', 129, 'VK_F18', e, e], - [77, 1, 116, 'F19', 77, 'F19', 130, 'VK_F19', e, e], - [0, 1, 117, 'F20', 0, e, 0, 'VK_F20', e, e], - [0, 1, 118, 'F21', 0, e, 0, 'VK_F21', e, e], - [0, 1, 119, 'F22', 0, e, 0, 'VK_F22', e, e], - [0, 1, 120, 'F23', 0, e, 0, 'VK_F23', e, e], - [0, 1, 121, 'F24', 0, e, 0, 'VK_F24', e, e], - [0, 1, 122, 'Open', 0, e, 0, e, e, e], - [0, 1, 123, 'Help', 0, e, 0, e, e, e], - [0, 1, 124, 'Select', 0, e, 0, e, e, e], - [0, 1, 125, 'Again', 0, e, 0, e, e, e], - [0, 1, 126, 'Undo', 0, e, 0, e, e, e], - [0, 1, 127, 'Cut', 0, e, 0, e, e, e], - [0, 1, 128, 'Copy', 0, e, 0, e, e, e], - [0, 1, 129, 'Paste', 0, e, 0, e, e, e], - [0, 1, 130, 'Find', 0, e, 0, e, e, e], - [0, 1, 131, 'AudioVolumeMute', 112, 'AudioVolumeMute', 173, 'VK_VOLUME_MUTE', e, e], - [0, 1, 132, 'AudioVolumeUp', 113, 'AudioVolumeUp', 175, 'VK_VOLUME_UP', e, e], - [0, 1, 133, 'AudioVolumeDown', 114, 'AudioVolumeDown', 174, 'VK_VOLUME_DOWN', e, e], - [105, 1, 134, 'NumpadComma', 105, 'NumPad_Separator', 108, 'VK_SEPARATOR', e, e], - [110, 0, 135, 'IntlRo', 110, 'ABNT_C1', 193, 'VK_ABNT_C1', e, e], - [0, 1, 136, 'KanaMode', 0, e, 0, e, e, e], - [0, 0, 137, 'IntlYen', 0, e, 0, e, e, e], - [0, 1, 138, 'Convert', 0, e, 0, e, e, e], - [0, 1, 139, 'NonConvert', 0, e, 0, e, e, e], - [0, 1, 140, 'Lang1', 0, e, 0, e, e, e], - [0, 1, 141, 'Lang2', 0, e, 0, e, e, e], - [0, 1, 142, 'Lang3', 0, e, 0, e, e, e], - [0, 1, 143, 'Lang4', 0, e, 0, e, e, e], - [0, 1, 144, 'Lang5', 0, e, 0, e, e, e], - [0, 1, 145, 'Abort', 0, e, 0, e, e, e], - [0, 1, 146, 'Props', 0, e, 0, e, e, e], - [0, 1, 147, 'NumpadParenLeft', 0, e, 0, e, e, e], - [0, 1, 148, 'NumpadParenRight', 0, e, 0, e, e, e], - [0, 1, 149, 'NumpadBackspace', 0, e, 0, e, e, e], - [0, 1, 150, 'NumpadMemoryStore', 0, e, 0, e, e, e], - [0, 1, 151, 'NumpadMemoryRecall', 0, e, 0, e, e, e], - [0, 1, 152, 'NumpadMemoryClear', 0, e, 0, e, e, e], - [0, 1, 153, 'NumpadMemoryAdd', 0, e, 0, e, e, e], - [0, 1, 154, 'NumpadMemorySubtract', 0, e, 0, e, e, e], - [0, 1, 155, 'NumpadClear', 126, 'Clear', 12, 'VK_CLEAR', e, e], - [0, 1, 156, 'NumpadClearEntry', 0, e, 0, e, e, e], - [5, 1, 0, e, 5, 'Ctrl', 17, 'VK_CONTROL', e, e], - [4, 1, 0, e, 4, 'Shift', 16, 'VK_SHIFT', e, e], - [6, 1, 0, e, 6, 'Alt', 18, 'VK_MENU', e, e], - [57, 1, 0, e, 57, 'Meta', 91, 'VK_COMMAND', e, e], - [5, 1, 157, 'ControlLeft', 5, e, 0, 'VK_LCONTROL', e, e], - [4, 1, 158, 'ShiftLeft', 4, e, 0, 'VK_LSHIFT', e, e], - [6, 1, 159, 'AltLeft', 6, e, 0, 'VK_LMENU', e, e], - [57, 1, 160, 'MetaLeft', 57, e, 0, 'VK_LWIN', e, e], - [5, 1, 161, 'ControlRight', 5, e, 0, 'VK_RCONTROL', e, e], - [4, 1, 162, 'ShiftRight', 4, e, 0, 'VK_RSHIFT', e, e], - [6, 1, 163, 'AltRight', 6, e, 0, 'VK_RMENU', e, e], - [57, 1, 164, 'MetaRight', 57, e, 0, 'VK_RWIN', e, e], - [0, 1, 165, 'BrightnessUp', 0, e, 0, e, e, e], - [0, 1, 166, 'BrightnessDown', 0, e, 0, e, e, e], - [0, 1, 167, 'MediaPlay', 0, e, 0, e, e, e], - [0, 1, 168, 'MediaRecord', 0, e, 0, e, e, e], - [0, 1, 169, 'MediaFastForward', 0, e, 0, e, e, e], - [0, 1, 170, 'MediaRewind', 0, e, 0, e, e, e], - [ - 114, - 1, - 171, - 'MediaTrackNext', - 119, - 'MediaTrackNext', - 176, - 'VK_MEDIA_NEXT_TRACK', - e, - e, - ], - [ - 115, - 1, - 172, - 'MediaTrackPrevious', - 120, - 'MediaTrackPrevious', - 177, - 'VK_MEDIA_PREV_TRACK', - e, - e, - ], - [116, 1, 173, 'MediaStop', 121, 'MediaStop', 178, 'VK_MEDIA_STOP', e, e], - [0, 1, 174, 'Eject', 0, e, 0, e, e, e], - [ - 117, - 1, - 175, - 'MediaPlayPause', - 122, - 'MediaPlayPause', - 179, - 'VK_MEDIA_PLAY_PAUSE', - e, - e, - ], - [ - 0, - 1, - 176, - 'MediaSelect', - 123, - 'LaunchMediaPlayer', - 181, - 'VK_MEDIA_LAUNCH_MEDIA_SELECT', - e, - e, - ], - [0, 1, 177, 'LaunchMail', 124, 'LaunchMail', 180, 'VK_MEDIA_LAUNCH_MAIL', e, e], - [0, 1, 178, 'LaunchApp2', 125, 'LaunchApp2', 183, 'VK_MEDIA_LAUNCH_APP2', e, e], - [0, 1, 179, 'LaunchApp1', 0, e, 0, 'VK_MEDIA_LAUNCH_APP1', e, e], - [0, 1, 180, 'SelectTask', 0, e, 0, e, e, e], - [0, 1, 181, 'LaunchScreenSaver', 0, e, 0, e, e, e], - [0, 1, 182, 'BrowserSearch', 115, 'BrowserSearch', 170, 'VK_BROWSER_SEARCH', e, e], - [0, 1, 183, 'BrowserHome', 116, 'BrowserHome', 172, 'VK_BROWSER_HOME', e, e], - [112, 1, 184, 'BrowserBack', 117, 'BrowserBack', 166, 'VK_BROWSER_BACK', e, e], - [113, 1, 185, 'BrowserForward', 118, 'BrowserForward', 167, 'VK_BROWSER_FORWARD', e, e], - [0, 1, 186, 'BrowserStop', 0, e, 0, 'VK_BROWSER_STOP', e, e], - [0, 1, 187, 'BrowserRefresh', 0, e, 0, 'VK_BROWSER_REFRESH', e, e], - [0, 1, 188, 'BrowserFavorites', 0, e, 0, 'VK_BROWSER_FAVORITES', e, e], - [0, 1, 189, 'ZoomToggle', 0, e, 0, e, e, e], - [0, 1, 190, 'MailReply', 0, e, 0, e, e, e], - [0, 1, 191, 'MailForward', 0, e, 0, e, e, e], - [0, 1, 192, 'MailSend', 0, e, 0, e, e, e], - [109, 1, 0, e, 109, 'KeyInComposition', 229, e, e, e], - [111, 1, 0, e, 111, 'ABNT_C2', 194, 'VK_ABNT_C2', e, e], - [91, 1, 0, e, 91, 'OEM_8', 223, 'VK_OEM_8', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_KANA', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_HANGUL', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_JUNJA', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_FINAL', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_HANJA', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_KANJI', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_CONVERT', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_NONCONVERT', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_ACCEPT', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_MODECHANGE', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_SELECT', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_PRINT', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_EXECUTE', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_SNAPSHOT', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_HELP', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_APPS', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_PROCESSKEY', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_PACKET', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_DBE_SBCSCHAR', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_DBE_DBCSCHAR', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_ATTN', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_CRSEL', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_EXSEL', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_EREOF', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_PLAY', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_ZOOM', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_NONAME', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_PA1', e, e], - [0, 1, 0, e, 0, e, 0, 'VK_OEM_CLEAR', e, e], - ], - f = [], - v = [] - for (const g of b) { - const [S, E, y, _, d, C, r, u, o, c] = g - if ( - (v[y] || - ((v[y] = !0), - (p[y] = _), - (L[_] = y), - (h[_.toLowerCase()] = y), - E && - ((n.IMMUTABLE_CODE_TO_KEY_CODE[y] = d), - d !== 0 && - d !== 3 && - d !== 5 && - d !== 4 && - d !== 6 && - d !== 57 && - (n.IMMUTABLE_KEY_CODE_TO_CODE[d] = y))), - !f[d]) - ) { - if (((f[d] = !0), !C)) - throw new Error( - `String representation missing for key code ${d} around scan code ${_}` - ) - D.define(d, C), i.define(d, o || C), s.define(d, c || o || C) - } - r && (n.EVENT_KEY_CODE_MAP[r] = d), u && (n.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE[u] = d) - } - n.IMMUTABLE_KEY_CODE_TO_CODE[3] = 46 - })() - var a - ;(function (e) { - function b(y) { - return D.keyCodeToStr(y) - } - e.toString = b - function f(y) { - return D.strToKeyCode(y) - } - e.fromString = f - function v(y) { - return i.keyCodeToStr(y) - } - e.toUserSettingsUS = v - function g(y) { - return s.keyCodeToStr(y) - } - e.toUserSettingsGeneral = g - function S(y) { - return i.strToKeyCode(y) || s.strToKeyCode(y) - } - e.fromUserSettings = S - function E(y) { - if (y >= 93 && y <= 108) return null - switch (y) { - case 16: - return 'Up' - case 18: - return 'Down' - case 15: - return 'Left' - case 17: - return 'Right' - } - return D.keyCodeToStr(y) - } - e.toElectronAccelerator = E - })((a = n.KeyCodeUtils || (n.KeyCodeUtils = {}))) - function w(e, b) { - const f = ((b & 65535) << 16) >>> 0 - return (e | f) >>> 0 - } - n.KeyChord = w - }), - Q(Y[30], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.Lazy = void 0) - class R { - constructor(i) { - ;(this.executor = i), (this._didRun = !1) - } - get value() { - if (!this._didRun) - try { - this._value = this.executor() - } catch (i) { - this._error = i - } finally { - this._didRun = !0 - } - if (this._error) throw this._error - return this._value - } - get rawValue() { - return this._value - } - } - n.Lazy = R - }), - Q(Y[11], X([0, 1, 14, 15]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.DisposableMap = - n.ImmortalReference = - n.SafeDisposable = - n.RefCountedDisposable = - n.MutableDisposable = - n.Disposable = - n.DisposableStore = - n.toDisposable = - n.combinedDisposable = - n.dispose = - n.isDisposable = - n.markAsSingleton = - n.setDisposableTracker = - void 0) - const i = !1 - let s = null - function p(u) { - s = u - } - if (((n.setDisposableTracker = p), i)) { - const u = '__is_disposable_tracked__' - p( - new (class { - trackDisposable(o) { - const c = new Error('Potentially leaked disposable').stack - setTimeout(() => { - o[u] || console.log(c) - }, 3e3) - } - setParent(o, c) { - if (o && o !== E.None) - try { - o[u] = !0 - } catch {} - } - markAsDisposed(o) { - if (o && o !== E.None) - try { - o[u] = !0 - } catch {} - } - markAsSingleton(o) {} - })() - ) - } - function L(u) { - return s?.trackDisposable(u), u - } - function h(u) { - s?.markAsDisposed(u) - } - function a(u, o) { - s?.setParent(u, o) - } - function w(u, o) { - if (!!s) for (const c of u) s.setParent(c, o) - } - function e(u) { - return s?.markAsSingleton(u), u - } - n.markAsSingleton = e - function b(u) { - return typeof u.dispose == 'function' && u.dispose.length === 0 - } - n.isDisposable = b - function f(u) { - if (D.Iterable.is(u)) { - const o = [] - for (const c of u) - if (c) - try { - c.dispose() - } catch (l) { - o.push(l) - } - if (o.length === 1) throw o[0] - if (o.length > 1) - throw new AggregateError(o, 'Encountered errors while disposing of store') - return Array.isArray(u) ? [] : u - } else if (u) return u.dispose(), u - } - n.dispose = f - function v(...u) { - const o = g(() => f(u)) - return w(u, o), o - } - n.combinedDisposable = v - function g(u) { - const o = L({ - dispose: (0, R.once)(() => { - h(o), u() - }), - }) - return o - } - n.toDisposable = g - class S { - constructor() { - ;(this._toDispose = new Set()), (this._isDisposed = !1), L(this) - } - dispose() { - this._isDisposed || (h(this), (this._isDisposed = !0), this.clear()) - } - get isDisposed() { - return this._isDisposed - } - clear() { - if (this._toDispose.size !== 0) - try { - f(this._toDispose) - } finally { - this._toDispose.clear() - } - } - add(o) { - if (!o) return o - if (o === this) throw new Error('Cannot register a disposable on itself!') - return ( - a(o, this), - this._isDisposed - ? S.DISABLE_DISPOSED_WARNING || - console.warn( - new Error( - 'Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!' - ).stack - ) - : this._toDispose.add(o), - o - ) - } - } - ;(S.DISABLE_DISPOSED_WARNING = !1), (n.DisposableStore = S) - class E { - constructor() { - ;(this._store = new S()), L(this), a(this._store, this) - } - dispose() { - h(this), this._store.dispose() - } - _register(o) { - if (o === this) throw new Error('Cannot register a disposable on itself!') - return this._store.add(o) - } - } - ;(E.None = Object.freeze({ dispose() {} })), (n.Disposable = E) - class y { - constructor() { - ;(this._isDisposed = !1), L(this) - } - get value() { - return this._isDisposed ? void 0 : this._value - } - set value(o) { - var c - this._isDisposed || - o === this._value || - ((c = this._value) === null || c === void 0 || c.dispose(), - o && a(o, this), - (this._value = o)) - } - clear() { - this.value = void 0 - } - dispose() { - var o - ;(this._isDisposed = !0), - h(this), - (o = this._value) === null || o === void 0 || o.dispose(), - (this._value = void 0) - } - clearAndLeak() { - const o = this._value - return (this._value = void 0), o && a(o, null), o - } - } - n.MutableDisposable = y - class _ { - constructor(o) { - ;(this._disposable = o), (this._counter = 1) - } - acquire() { - return this._counter++, this - } - release() { - return --this._counter === 0 && this._disposable.dispose(), this - } - } - n.RefCountedDisposable = _ - class d { - constructor() { - ;(this.dispose = () => {}), (this.unset = () => {}), (this.isset = () => !1), L(this) - } - set(o) { - let c = o - return ( - (this.unset = () => (c = void 0)), - (this.isset = () => c !== void 0), - (this.dispose = () => { - c && (c(), (c = void 0), h(this)) - }), - this - ) - } - } - n.SafeDisposable = d - class C { - constructor(o) { - this.object = o - } - dispose() {} - } - n.ImmortalReference = C - class r { - constructor() { - ;(this._store = new Map()), (this._isDisposed = !1), L(this) - } - dispose() { - h(this), (this._isDisposed = !0), this.clearAndDisposeAll() - } - clearAndDisposeAll() { - if (!!this._store.size) - try { - f(this._store.values()) - } finally { - this._store.clear() - } - } - get(o) { - return this._store.get(o) - } - set(o, c, l = !1) { - var m - this._isDisposed && - console.warn( - new Error( - 'Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!' - ).stack - ), - l || (m = this._store.get(o)) === null || m === void 0 || m.dispose(), - this._store.set(o, c) - } - deleteAndDispose(o) { - var c - ;(c = this._store.get(o)) === null || c === void 0 || c.dispose(), this._store.delete(o) - } - [Symbol.iterator]() { - return this._store[Symbol.iterator]() - } - } - n.DisposableMap = r - }), - Q(Y[16], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.LinkedList = void 0) - class R { - constructor(s) { - ;(this.element = s), (this.next = R.Undefined), (this.prev = R.Undefined) - } - } - R.Undefined = new R(void 0) - class D { - constructor() { - ;(this._first = R.Undefined), (this._last = R.Undefined), (this._size = 0) - } - get size() { - return this._size - } - isEmpty() { - return this._first === R.Undefined - } - clear() { - let s = this._first - for (; s !== R.Undefined; ) { - const p = s.next - ;(s.prev = R.Undefined), (s.next = R.Undefined), (s = p) - } - ;(this._first = R.Undefined), (this._last = R.Undefined), (this._size = 0) - } - unshift(s) { - return this._insert(s, !1) - } - push(s) { - return this._insert(s, !0) - } - _insert(s, p) { - const L = new R(s) - if (this._first === R.Undefined) (this._first = L), (this._last = L) - else if (p) { - const a = this._last - ;(this._last = L), (L.prev = a), (a.next = L) - } else { - const a = this._first - ;(this._first = L), (L.next = a), (a.prev = L) - } - this._size += 1 - let h = !1 - return () => { - h || ((h = !0), this._remove(L)) - } - } - shift() { - if (this._first !== R.Undefined) { - const s = this._first.element - return this._remove(this._first), s - } - } - pop() { - if (this._last !== R.Undefined) { - const s = this._last.element - return this._remove(this._last), s - } - } - _remove(s) { - if (s.prev !== R.Undefined && s.next !== R.Undefined) { - const p = s.prev - ;(p.next = s.next), (s.next.prev = p) - } else s.prev === R.Undefined && s.next === R.Undefined ? ((this._first = R.Undefined), (this._last = R.Undefined)) : s.next === R.Undefined ? ((this._last = this._last.prev), (this._last.next = R.Undefined)) : s.prev === R.Undefined && ((this._first = this._first.next), (this._first.prev = R.Undefined)) - this._size -= 1 - } - *[Symbol.iterator]() { - let s = this._first - for (; s !== R.Undefined; ) yield s.element, (s = s.next) - } - } - n.LinkedList = D - }), - Q(Y[5], X([0, 1, 27, 30]), function (x, n, R, D) { - 'use strict' - var i - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.InvisibleCharacters = - n.AmbiguousCharacters = - n.noBreakWhitespace = - n.getLeftDeleteOffset = - n.singleLetterHash = - n.containsUppercaseCharacter = - n.startsWithUTF8BOM = - n.UTF8_BOM_CHARACTER = - n.isEmojiImprecise = - n.isFullWidthCharacter = - n.containsUnusualLineTerminators = - n.UNUSUAL_LINE_TERMINATORS = - n.isBasicASCII = - n.containsRTL = - n.getCharContainingOffset = - n.prevCharLength = - n.nextCharLength = - n.GraphemeIterator = - n.CodePointIterator = - n.getNextCodePoint = - n.computeCodePoint = - n.isLowSurrogate = - n.isHighSurrogate = - n.commonSuffixLength = - n.commonPrefixLength = - n.startsWithIgnoreCase = - n.equalsIgnoreCase = - n.isUpperAsciiLetter = - n.isLowerAsciiLetter = - n.isAsciiDigit = - n.compareSubstringIgnoreCase = - n.compareIgnoreCase = - n.compareSubstring = - n.compare = - n.lastNonWhitespaceIndex = - n.getLeadingWhitespace = - n.firstNonWhitespaceIndex = - n.splitLines = - n.regExpFlags = - n.regExpLeadsToEndlessLoop = - n.createRegExp = - n.stripWildcards = - n.convertSimple2RegExpPattern = - n.rtrim = - n.ltrim = - n.trim = - n.escapeRegExpCharacters = - n.escape = - n.format = - n.isFalsyOrWhitespace = - void 0) - function s(P) { - return !P || typeof P != 'string' ? !0 : P.trim().length === 0 - } - n.isFalsyOrWhitespace = s - const p = /{(\d+)}/g - function L(P, ...O) { - return O.length === 0 - ? P - : P.replace(p, function (V, j) { - const Z = parseInt(j, 10) - return isNaN(Z) || Z < 0 || Z >= O.length ? V : O[Z] - }) - } - n.format = L - function h(P) { - return P.replace(/[<>&]/g, function (O) { - switch (O) { - case '<': - return '<' - case '>': - return '>' - case '&': - return '&' - default: - return O - } - }) - } - n.escape = h - function a(P) { - return P.replace(/[\\\{\}\*\+\?\|\^\$\.\[\]\(\)]/g, '\\$&') - } - n.escapeRegExpCharacters = a - function w(P, O = ' ') { - const V = e(P, O) - return b(V, O) - } - n.trim = w - function e(P, O) { - if (!P || !O) return P - const V = O.length - if (V === 0 || P.length === 0) return P - let j = 0 - for (; P.indexOf(O, j) === j; ) j = j + V - return P.substring(j) - } - n.ltrim = e - function b(P, O) { - if (!P || !O) return P - const V = O.length, - j = P.length - if (V === 0 || j === 0) return P - let Z = j, - le = -1 - for (; (le = P.lastIndexOf(O, Z - 1)), !(le === -1 || le + V !== Z); ) { - if (le === 0) return '' - Z = le - } - return P.substring(0, Z) - } - n.rtrim = b - function f(P) { - return P.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g, '\\$&').replace(/[\*]/g, '.*') - } - n.convertSimple2RegExpPattern = f - function v(P) { - return P.replace(/\*/g, '') - } - n.stripWildcards = v - function g(P, O, V = {}) { - if (!P) throw new Error('Cannot create regex from empty string') - O || (P = a(P)), - V.wholeWord && - (/\B/.test(P.charAt(0)) || (P = '\\b' + P), - /\B/.test(P.charAt(P.length - 1)) || (P = P + '\\b')) - let j = '' - return ( - V.global && (j += 'g'), - V.matchCase || (j += 'i'), - V.multiline && (j += 'm'), - V.unicode && (j += 'u'), - new RegExp(P, j) - ) - } - n.createRegExp = g - function S(P) { - return P.source === '^' || P.source === '^$' || P.source === '$' || P.source === '^\\s*$' - ? !1 - : !!(P.exec('') && P.lastIndex === 0) - } - n.regExpLeadsToEndlessLoop = S - function E(P) { - return ( - (P.global ? 'g' : '') + - (P.ignoreCase ? 'i' : '') + - (P.multiline ? 'm' : '') + - (P.unicode ? 'u' : '') - ) - } - n.regExpFlags = E - function y(P) { - return P.split(/\r\n|\r|\n/) - } - n.splitLines = y - function _(P) { - for (let O = 0, V = P.length; O < V; O++) { - const j = P.charCodeAt(O) - if (j !== 32 && j !== 9) return O - } - return -1 - } - n.firstNonWhitespaceIndex = _ - function d(P, O = 0, V = P.length) { - for (let j = O; j < V; j++) { - const Z = P.charCodeAt(j) - if (Z !== 32 && Z !== 9) return P.substring(O, j) - } - return P.substring(O, V) - } - n.getLeadingWhitespace = d - function C(P, O = P.length - 1) { - for (let V = O; V >= 0; V--) { - const j = P.charCodeAt(V) - if (j !== 32 && j !== 9) return V - } - return -1 - } - n.lastNonWhitespaceIndex = C - function r(P, O) { - return P < O ? -1 : P > O ? 1 : 0 - } - n.compare = r - function u(P, O, V = 0, j = P.length, Z = 0, le = O.length) { - for (; V < j && Z < le; V++, Z++) { - const ve = P.charCodeAt(V), - oe = O.charCodeAt(Z) - if (ve < oe) return -1 - if (ve > oe) return 1 - } - const he = j - V, - ye = le - Z - return he < ye ? -1 : he > ye ? 1 : 0 - } - n.compareSubstring = u - function o(P, O) { - return c(P, O, 0, P.length, 0, O.length) - } - n.compareIgnoreCase = o - function c(P, O, V = 0, j = P.length, Z = 0, le = O.length) { - for (; V < j && Z < le; V++, Z++) { - let ve = P.charCodeAt(V), - oe = O.charCodeAt(Z) - if (ve === oe) continue - if (ve >= 128 || oe >= 128) return u(P.toLowerCase(), O.toLowerCase(), V, j, Z, le) - m(ve) && (ve -= 32), m(oe) && (oe -= 32) - const _e = ve - oe - if (_e !== 0) return _e - } - const he = j - V, - ye = le - Z - return he < ye ? -1 : he > ye ? 1 : 0 - } - n.compareSubstringIgnoreCase = c - function l(P) { - return P >= 48 && P <= 57 - } - n.isAsciiDigit = l - function m(P) { - return P >= 97 && P <= 122 - } - n.isLowerAsciiLetter = m - function N(P) { - return P >= 65 && P <= 90 - } - n.isUpperAsciiLetter = N - function A(P, O) { - return P.length === O.length && c(P, O) === 0 - } - n.equalsIgnoreCase = A - function M(P, O) { - const V = O.length - return O.length > P.length ? !1 : c(P, O, 0, V) === 0 - } - n.startsWithIgnoreCase = M - function k(P, O) { - const V = Math.min(P.length, O.length) - let j - for (j = 0; j < V; j++) if (P.charCodeAt(j) !== O.charCodeAt(j)) return j - return V - } - n.commonPrefixLength = k - function q(P, O) { - const V = Math.min(P.length, O.length) - let j - const Z = P.length - 1, - le = O.length - 1 - for (j = 0; j < V; j++) if (P.charCodeAt(Z - j) !== O.charCodeAt(le - j)) return j - return V - } - n.commonSuffixLength = q - function I(P) { - return 55296 <= P && P <= 56319 - } - n.isHighSurrogate = I - function B(P) { - return 56320 <= P && P <= 57343 - } - n.isLowSurrogate = B - function H(P, O) { - return ((P - 55296) << 10) + (O - 56320) + 65536 - } - n.computeCodePoint = H - function F(P, O, V) { - const j = P.charCodeAt(V) - if (I(j) && V + 1 < O) { - const Z = P.charCodeAt(V + 1) - if (B(Z)) return H(j, Z) - } - return j - } - n.getNextCodePoint = F - function U(P, O) { - const V = P.charCodeAt(O - 1) - if (B(V) && O > 1) { - const j = P.charCodeAt(O - 2) - if (I(j)) return H(j, V) - } - return V - } - class T { - get offset() { - return this._offset - } - constructor(O, V = 0) { - ;(this._str = O), (this._len = O.length), (this._offset = V) - } - setOffset(O) { - this._offset = O - } - prevCodePoint() { - const O = U(this._str, this._offset) - return (this._offset -= O >= 65536 ? 2 : 1), O - } - nextCodePoint() { - const O = F(this._str, this._len, this._offset) - return (this._offset += O >= 65536 ? 2 : 1), O - } - eol() { - return this._offset >= this._len - } - } - n.CodePointIterator = T - class W { - get offset() { - return this._iterator.offset - } - constructor(O, V = 0) { - this._iterator = new T(O, V) - } - nextGraphemeLength() { - const O = ee.getInstance(), - V = this._iterator, - j = V.offset - let Z = O.getGraphemeBreakType(V.nextCodePoint()) - for (; !V.eol(); ) { - const le = V.offset, - he = O.getGraphemeBreakType(V.nextCodePoint()) - if (K(Z, he)) { - V.setOffset(le) - break - } - Z = he - } - return V.offset - j - } - prevGraphemeLength() { - const O = ee.getInstance(), - V = this._iterator, - j = V.offset - let Z = O.getGraphemeBreakType(V.prevCodePoint()) - for (; V.offset > 0; ) { - const le = V.offset, - he = O.getGraphemeBreakType(V.prevCodePoint()) - if (K(he, Z)) { - V.setOffset(le) - break - } - Z = he - } - return j - V.offset - } - eol() { - return this._iterator.eol() - } - } - n.GraphemeIterator = W - function t(P, O) { - return new W(P, O).nextGraphemeLength() - } - n.nextCharLength = t - function te(P, O) { - return new W(P, O).prevGraphemeLength() - } - n.prevCharLength = te - function ie(P, O) { - O > 0 && B(P.charCodeAt(O)) && O-- - const V = O + t(P, O) - return [V - te(P, V), V] - } - n.getCharContainingOffset = ie - let ue - function de() { - return /(?:[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05F4\u0608\u060B\u060D\u061B-\u064A\u066D-\u066F\u0671-\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u0710\u0712-\u072F\u074D-\u07A5\u07B1-\u07EA\u07F4\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u0858\u085E-\u088E\u08A0-\u08C9\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFD3D\uFD50-\uFDC7\uFDF0-\uFDFC\uFE70-\uFEFC]|\uD802[\uDC00-\uDD1B\uDD20-\uDE00\uDE10-\uDE35\uDE40-\uDEE4\uDEEB-\uDF35\uDF40-\uDFFF]|\uD803[\uDC00-\uDD23\uDE80-\uDEA9\uDEAD-\uDF45\uDF51-\uDF81\uDF86-\uDFF6]|\uD83A[\uDC00-\uDCCF\uDD00-\uDD43\uDD4B-\uDFFF]|\uD83B[\uDC00-\uDEBB])/ - } - function Ce(P) { - return ue || (ue = de()), ue.test(P) - } - n.containsRTL = Ce - const re = /^[\t\n\r\x20-\x7E]*$/ - function se(P) { - return re.test(P) - } - ;(n.isBasicASCII = se), (n.UNUSUAL_LINE_TERMINATORS = /[\u2028\u2029]/) - function ge(P) { - return n.UNUSUAL_LINE_TERMINATORS.test(P) - } - n.containsUnusualLineTerminators = ge - function Le(P) { - return ( - (P >= 11904 && P <= 55215) || (P >= 63744 && P <= 64255) || (P >= 65281 && P <= 65374) - ) - } - n.isFullWidthCharacter = Le - function J(P) { - return ( - (P >= 127462 && P <= 127487) || - P === 8986 || - P === 8987 || - P === 9200 || - P === 9203 || - (P >= 9728 && P <= 10175) || - P === 11088 || - P === 11093 || - (P >= 127744 && P <= 128591) || - (P >= 128640 && P <= 128764) || - (P >= 128992 && P <= 129008) || - (P >= 129280 && P <= 129535) || - (P >= 129648 && P <= 129782) - ) - } - ;(n.isEmojiImprecise = J), (n.UTF8_BOM_CHARACTER = String.fromCharCode(65279)) - function z(P) { - return !!(P && P.length > 0 && P.charCodeAt(0) === 65279) - } - n.startsWithUTF8BOM = z - function G(P, O = !1) { - return P ? (O && (P = P.replace(/\\./g, '')), P.toLowerCase() !== P) : !1 - } - n.containsUppercaseCharacter = G - function $(P) { - return ( - (P = P % (2 * 26)), - P < 26 ? String.fromCharCode(97 + P) : String.fromCharCode(65 + P - 26) - ) - } - n.singleLetterHash = $ - function K(P, O) { - return P === 0 - ? O !== 5 && O !== 7 - : P === 2 && O === 3 - ? !1 - : P === 4 || P === 2 || P === 3 || O === 4 || O === 2 || O === 3 - ? !0 - : !( - (P === 8 && (O === 8 || O === 9 || O === 11 || O === 12)) || - ((P === 11 || P === 9) && (O === 9 || O === 10)) || - ((P === 12 || P === 10) && O === 10) || - O === 5 || - O === 13 || - O === 7 || - P === 1 || - (P === 13 && O === 14) || - (P === 6 && O === 6) - ) - } - class ee { - static getInstance() { - return ee._INSTANCE || (ee._INSTANCE = new ee()), ee._INSTANCE - } - constructor() { - this._data = ne() - } - getGraphemeBreakType(O) { - if (O < 32) return O === 10 ? 3 : O === 13 ? 2 : 4 - if (O < 127) return 0 - const V = this._data, - j = V.length / 3 - let Z = 1 - for (; Z <= j; ) - if (O < V[3 * Z]) Z = 2 * Z - else if (O > V[3 * Z + 1]) Z = 2 * Z + 1 - else return V[3 * Z + 2] - return 0 - } - } - ee._INSTANCE = null - function ne() { - return JSON.parse( - '[0,0,0,51229,51255,12,44061,44087,12,127462,127487,6,7083,7085,5,47645,47671,12,54813,54839,12,128678,128678,14,3270,3270,5,9919,9923,14,45853,45879,12,49437,49463,12,53021,53047,12,71216,71218,7,128398,128399,14,129360,129374,14,2519,2519,5,4448,4519,9,9742,9742,14,12336,12336,14,44957,44983,12,46749,46775,12,48541,48567,12,50333,50359,12,52125,52151,12,53917,53943,12,69888,69890,5,73018,73018,5,127990,127990,14,128558,128559,14,128759,128760,14,129653,129655,14,2027,2035,5,2891,2892,7,3761,3761,5,6683,6683,5,8293,8293,4,9825,9826,14,9999,9999,14,43452,43453,5,44509,44535,12,45405,45431,12,46301,46327,12,47197,47223,12,48093,48119,12,48989,49015,12,49885,49911,12,50781,50807,12,51677,51703,12,52573,52599,12,53469,53495,12,54365,54391,12,65279,65279,4,70471,70472,7,72145,72147,7,119173,119179,5,127799,127818,14,128240,128244,14,128512,128512,14,128652,128652,14,128721,128722,14,129292,129292,14,129445,129450,14,129734,129743,14,1476,1477,5,2366,2368,7,2750,2752,7,3076,3076,5,3415,3415,5,4141,4144,5,6109,6109,5,6964,6964,5,7394,7400,5,9197,9198,14,9770,9770,14,9877,9877,14,9968,9969,14,10084,10084,14,43052,43052,5,43713,43713,5,44285,44311,12,44733,44759,12,45181,45207,12,45629,45655,12,46077,46103,12,46525,46551,12,46973,46999,12,47421,47447,12,47869,47895,12,48317,48343,12,48765,48791,12,49213,49239,12,49661,49687,12,50109,50135,12,50557,50583,12,51005,51031,12,51453,51479,12,51901,51927,12,52349,52375,12,52797,52823,12,53245,53271,12,53693,53719,12,54141,54167,12,54589,54615,12,55037,55063,12,69506,69509,5,70191,70193,5,70841,70841,7,71463,71467,5,72330,72342,5,94031,94031,5,123628,123631,5,127763,127765,14,127941,127941,14,128043,128062,14,128302,128317,14,128465,128467,14,128539,128539,14,128640,128640,14,128662,128662,14,128703,128703,14,128745,128745,14,129004,129007,14,129329,129330,14,129402,129402,14,129483,129483,14,129686,129704,14,130048,131069,14,173,173,4,1757,1757,1,2200,2207,5,2434,2435,7,2631,2632,5,2817,2817,5,3008,3008,5,3201,3201,5,3387,3388,5,3542,3542,5,3902,3903,7,4190,4192,5,6002,6003,5,6439,6440,5,6765,6770,7,7019,7027,5,7154,7155,7,8205,8205,13,8505,8505,14,9654,9654,14,9757,9757,14,9792,9792,14,9852,9853,14,9890,9894,14,9937,9937,14,9981,9981,14,10035,10036,14,11035,11036,14,42654,42655,5,43346,43347,7,43587,43587,5,44006,44007,7,44173,44199,12,44397,44423,12,44621,44647,12,44845,44871,12,45069,45095,12,45293,45319,12,45517,45543,12,45741,45767,12,45965,45991,12,46189,46215,12,46413,46439,12,46637,46663,12,46861,46887,12,47085,47111,12,47309,47335,12,47533,47559,12,47757,47783,12,47981,48007,12,48205,48231,12,48429,48455,12,48653,48679,12,48877,48903,12,49101,49127,12,49325,49351,12,49549,49575,12,49773,49799,12,49997,50023,12,50221,50247,12,50445,50471,12,50669,50695,12,50893,50919,12,51117,51143,12,51341,51367,12,51565,51591,12,51789,51815,12,52013,52039,12,52237,52263,12,52461,52487,12,52685,52711,12,52909,52935,12,53133,53159,12,53357,53383,12,53581,53607,12,53805,53831,12,54029,54055,12,54253,54279,12,54477,54503,12,54701,54727,12,54925,54951,12,55149,55175,12,68101,68102,5,69762,69762,7,70067,70069,7,70371,70378,5,70720,70721,7,71087,71087,5,71341,71341,5,71995,71996,5,72249,72249,7,72850,72871,5,73109,73109,5,118576,118598,5,121505,121519,5,127245,127247,14,127568,127569,14,127777,127777,14,127872,127891,14,127956,127967,14,128015,128016,14,128110,128172,14,128259,128259,14,128367,128368,14,128424,128424,14,128488,128488,14,128530,128532,14,128550,128551,14,128566,128566,14,128647,128647,14,128656,128656,14,128667,128673,14,128691,128693,14,128715,128715,14,128728,128732,14,128752,128752,14,128765,128767,14,129096,129103,14,129311,129311,14,129344,129349,14,129394,129394,14,129413,129425,14,129466,129471,14,129511,129535,14,129664,129666,14,129719,129722,14,129760,129767,14,917536,917631,5,13,13,2,1160,1161,5,1564,1564,4,1807,1807,1,2085,2087,5,2307,2307,7,2382,2383,7,2497,2500,5,2563,2563,7,2677,2677,5,2763,2764,7,2879,2879,5,2914,2915,5,3021,3021,5,3142,3144,5,3263,3263,5,3285,3286,5,3398,3400,7,3530,3530,5,3633,3633,5,3864,3865,5,3974,3975,5,4155,4156,7,4229,4230,5,5909,5909,7,6078,6085,7,6277,6278,5,6451,6456,7,6744,6750,5,6846,6846,5,6972,6972,5,7074,7077,5,7146,7148,7,7222,7223,5,7416,7417,5,8234,8238,4,8417,8417,5,9000,9000,14,9203,9203,14,9730,9731,14,9748,9749,14,9762,9763,14,9776,9783,14,9800,9811,14,9831,9831,14,9872,9873,14,9882,9882,14,9900,9903,14,9929,9933,14,9941,9960,14,9974,9974,14,9989,9989,14,10006,10006,14,10062,10062,14,10160,10160,14,11647,11647,5,12953,12953,14,43019,43019,5,43232,43249,5,43443,43443,5,43567,43568,7,43696,43696,5,43765,43765,7,44013,44013,5,44117,44143,12,44229,44255,12,44341,44367,12,44453,44479,12,44565,44591,12,44677,44703,12,44789,44815,12,44901,44927,12,45013,45039,12,45125,45151,12,45237,45263,12,45349,45375,12,45461,45487,12,45573,45599,12,45685,45711,12,45797,45823,12,45909,45935,12,46021,46047,12,46133,46159,12,46245,46271,12,46357,46383,12,46469,46495,12,46581,46607,12,46693,46719,12,46805,46831,12,46917,46943,12,47029,47055,12,47141,47167,12,47253,47279,12,47365,47391,12,47477,47503,12,47589,47615,12,47701,47727,12,47813,47839,12,47925,47951,12,48037,48063,12,48149,48175,12,48261,48287,12,48373,48399,12,48485,48511,12,48597,48623,12,48709,48735,12,48821,48847,12,48933,48959,12,49045,49071,12,49157,49183,12,49269,49295,12,49381,49407,12,49493,49519,12,49605,49631,12,49717,49743,12,49829,49855,12,49941,49967,12,50053,50079,12,50165,50191,12,50277,50303,12,50389,50415,12,50501,50527,12,50613,50639,12,50725,50751,12,50837,50863,12,50949,50975,12,51061,51087,12,51173,51199,12,51285,51311,12,51397,51423,12,51509,51535,12,51621,51647,12,51733,51759,12,51845,51871,12,51957,51983,12,52069,52095,12,52181,52207,12,52293,52319,12,52405,52431,12,52517,52543,12,52629,52655,12,52741,52767,12,52853,52879,12,52965,52991,12,53077,53103,12,53189,53215,12,53301,53327,12,53413,53439,12,53525,53551,12,53637,53663,12,53749,53775,12,53861,53887,12,53973,53999,12,54085,54111,12,54197,54223,12,54309,54335,12,54421,54447,12,54533,54559,12,54645,54671,12,54757,54783,12,54869,54895,12,54981,55007,12,55093,55119,12,55243,55291,10,66045,66045,5,68325,68326,5,69688,69702,5,69817,69818,5,69957,69958,7,70089,70092,5,70198,70199,5,70462,70462,5,70502,70508,5,70750,70750,5,70846,70846,7,71100,71101,5,71230,71230,7,71351,71351,5,71737,71738,5,72000,72000,7,72160,72160,5,72273,72278,5,72752,72758,5,72882,72883,5,73031,73031,5,73461,73462,7,94192,94193,7,119149,119149,7,121403,121452,5,122915,122916,5,126980,126980,14,127358,127359,14,127535,127535,14,127759,127759,14,127771,127771,14,127792,127793,14,127825,127867,14,127897,127899,14,127945,127945,14,127985,127986,14,128000,128007,14,128021,128021,14,128066,128100,14,128184,128235,14,128249,128252,14,128266,128276,14,128335,128335,14,128379,128390,14,128407,128419,14,128444,128444,14,128481,128481,14,128499,128499,14,128526,128526,14,128536,128536,14,128543,128543,14,128556,128556,14,128564,128564,14,128577,128580,14,128643,128645,14,128649,128649,14,128654,128654,14,128660,128660,14,128664,128664,14,128675,128675,14,128686,128689,14,128695,128696,14,128705,128709,14,128717,128719,14,128725,128725,14,128736,128741,14,128747,128748,14,128755,128755,14,128762,128762,14,128981,128991,14,129009,129023,14,129160,129167,14,129296,129304,14,129320,129327,14,129340,129342,14,129356,129356,14,129388,129392,14,129399,129400,14,129404,129407,14,129432,129442,14,129454,129455,14,129473,129474,14,129485,129487,14,129648,129651,14,129659,129660,14,129671,129679,14,129709,129711,14,129728,129730,14,129751,129753,14,129776,129782,14,917505,917505,4,917760,917999,5,10,10,3,127,159,4,768,879,5,1471,1471,5,1536,1541,1,1648,1648,5,1767,1768,5,1840,1866,5,2070,2073,5,2137,2139,5,2274,2274,1,2363,2363,7,2377,2380,7,2402,2403,5,2494,2494,5,2507,2508,7,2558,2558,5,2622,2624,7,2641,2641,5,2691,2691,7,2759,2760,5,2786,2787,5,2876,2876,5,2881,2884,5,2901,2902,5,3006,3006,5,3014,3016,7,3072,3072,5,3134,3136,5,3157,3158,5,3260,3260,5,3266,3266,5,3274,3275,7,3328,3329,5,3391,3392,7,3405,3405,5,3457,3457,5,3536,3537,7,3551,3551,5,3636,3642,5,3764,3772,5,3895,3895,5,3967,3967,7,3993,4028,5,4146,4151,5,4182,4183,7,4226,4226,5,4253,4253,5,4957,4959,5,5940,5940,7,6070,6070,7,6087,6088,7,6158,6158,4,6432,6434,5,6448,6449,7,6679,6680,5,6742,6742,5,6754,6754,5,6783,6783,5,6912,6915,5,6966,6970,5,6978,6978,5,7042,7042,7,7080,7081,5,7143,7143,7,7150,7150,7,7212,7219,5,7380,7392,5,7412,7412,5,8203,8203,4,8232,8232,4,8265,8265,14,8400,8412,5,8421,8432,5,8617,8618,14,9167,9167,14,9200,9200,14,9410,9410,14,9723,9726,14,9733,9733,14,9745,9745,14,9752,9752,14,9760,9760,14,9766,9766,14,9774,9774,14,9786,9786,14,9794,9794,14,9823,9823,14,9828,9828,14,9833,9850,14,9855,9855,14,9875,9875,14,9880,9880,14,9885,9887,14,9896,9897,14,9906,9916,14,9926,9927,14,9935,9935,14,9939,9939,14,9962,9962,14,9972,9972,14,9978,9978,14,9986,9986,14,9997,9997,14,10002,10002,14,10017,10017,14,10055,10055,14,10071,10071,14,10133,10135,14,10548,10549,14,11093,11093,14,12330,12333,5,12441,12442,5,42608,42610,5,43010,43010,5,43045,43046,5,43188,43203,7,43302,43309,5,43392,43394,5,43446,43449,5,43493,43493,5,43571,43572,7,43597,43597,7,43703,43704,5,43756,43757,5,44003,44004,7,44009,44010,7,44033,44059,12,44089,44115,12,44145,44171,12,44201,44227,12,44257,44283,12,44313,44339,12,44369,44395,12,44425,44451,12,44481,44507,12,44537,44563,12,44593,44619,12,44649,44675,12,44705,44731,12,44761,44787,12,44817,44843,12,44873,44899,12,44929,44955,12,44985,45011,12,45041,45067,12,45097,45123,12,45153,45179,12,45209,45235,12,45265,45291,12,45321,45347,12,45377,45403,12,45433,45459,12,45489,45515,12,45545,45571,12,45601,45627,12,45657,45683,12,45713,45739,12,45769,45795,12,45825,45851,12,45881,45907,12,45937,45963,12,45993,46019,12,46049,46075,12,46105,46131,12,46161,46187,12,46217,46243,12,46273,46299,12,46329,46355,12,46385,46411,12,46441,46467,12,46497,46523,12,46553,46579,12,46609,46635,12,46665,46691,12,46721,46747,12,46777,46803,12,46833,46859,12,46889,46915,12,46945,46971,12,47001,47027,12,47057,47083,12,47113,47139,12,47169,47195,12,47225,47251,12,47281,47307,12,47337,47363,12,47393,47419,12,47449,47475,12,47505,47531,12,47561,47587,12,47617,47643,12,47673,47699,12,47729,47755,12,47785,47811,12,47841,47867,12,47897,47923,12,47953,47979,12,48009,48035,12,48065,48091,12,48121,48147,12,48177,48203,12,48233,48259,12,48289,48315,12,48345,48371,12,48401,48427,12,48457,48483,12,48513,48539,12,48569,48595,12,48625,48651,12,48681,48707,12,48737,48763,12,48793,48819,12,48849,48875,12,48905,48931,12,48961,48987,12,49017,49043,12,49073,49099,12,49129,49155,12,49185,49211,12,49241,49267,12,49297,49323,12,49353,49379,12,49409,49435,12,49465,49491,12,49521,49547,12,49577,49603,12,49633,49659,12,49689,49715,12,49745,49771,12,49801,49827,12,49857,49883,12,49913,49939,12,49969,49995,12,50025,50051,12,50081,50107,12,50137,50163,12,50193,50219,12,50249,50275,12,50305,50331,12,50361,50387,12,50417,50443,12,50473,50499,12,50529,50555,12,50585,50611,12,50641,50667,12,50697,50723,12,50753,50779,12,50809,50835,12,50865,50891,12,50921,50947,12,50977,51003,12,51033,51059,12,51089,51115,12,51145,51171,12,51201,51227,12,51257,51283,12,51313,51339,12,51369,51395,12,51425,51451,12,51481,51507,12,51537,51563,12,51593,51619,12,51649,51675,12,51705,51731,12,51761,51787,12,51817,51843,12,51873,51899,12,51929,51955,12,51985,52011,12,52041,52067,12,52097,52123,12,52153,52179,12,52209,52235,12,52265,52291,12,52321,52347,12,52377,52403,12,52433,52459,12,52489,52515,12,52545,52571,12,52601,52627,12,52657,52683,12,52713,52739,12,52769,52795,12,52825,52851,12,52881,52907,12,52937,52963,12,52993,53019,12,53049,53075,12,53105,53131,12,53161,53187,12,53217,53243,12,53273,53299,12,53329,53355,12,53385,53411,12,53441,53467,12,53497,53523,12,53553,53579,12,53609,53635,12,53665,53691,12,53721,53747,12,53777,53803,12,53833,53859,12,53889,53915,12,53945,53971,12,54001,54027,12,54057,54083,12,54113,54139,12,54169,54195,12,54225,54251,12,54281,54307,12,54337,54363,12,54393,54419,12,54449,54475,12,54505,54531,12,54561,54587,12,54617,54643,12,54673,54699,12,54729,54755,12,54785,54811,12,54841,54867,12,54897,54923,12,54953,54979,12,55009,55035,12,55065,55091,12,55121,55147,12,55177,55203,12,65024,65039,5,65520,65528,4,66422,66426,5,68152,68154,5,69291,69292,5,69633,69633,5,69747,69748,5,69811,69814,5,69826,69826,5,69932,69932,7,70016,70017,5,70079,70080,7,70095,70095,5,70196,70196,5,70367,70367,5,70402,70403,7,70464,70464,5,70487,70487,5,70709,70711,7,70725,70725,7,70833,70834,7,70843,70844,7,70849,70849,7,71090,71093,5,71103,71104,5,71227,71228,7,71339,71339,5,71344,71349,5,71458,71461,5,71727,71735,5,71985,71989,7,71998,71998,5,72002,72002,7,72154,72155,5,72193,72202,5,72251,72254,5,72281,72283,5,72344,72345,5,72766,72766,7,72874,72880,5,72885,72886,5,73023,73029,5,73104,73105,5,73111,73111,5,92912,92916,5,94095,94098,5,113824,113827,4,119142,119142,7,119155,119162,4,119362,119364,5,121476,121476,5,122888,122904,5,123184,123190,5,125252,125258,5,127183,127183,14,127340,127343,14,127377,127386,14,127491,127503,14,127548,127551,14,127744,127756,14,127761,127761,14,127769,127769,14,127773,127774,14,127780,127788,14,127796,127797,14,127820,127823,14,127869,127869,14,127894,127895,14,127902,127903,14,127943,127943,14,127947,127950,14,127972,127972,14,127988,127988,14,127992,127994,14,128009,128011,14,128019,128019,14,128023,128041,14,128064,128064,14,128102,128107,14,128174,128181,14,128238,128238,14,128246,128247,14,128254,128254,14,128264,128264,14,128278,128299,14,128329,128330,14,128348,128359,14,128371,128377,14,128392,128393,14,128401,128404,14,128421,128421,14,128433,128434,14,128450,128452,14,128476,128478,14,128483,128483,14,128495,128495,14,128506,128506,14,128519,128520,14,128528,128528,14,128534,128534,14,128538,128538,14,128540,128542,14,128544,128549,14,128552,128555,14,128557,128557,14,128560,128563,14,128565,128565,14,128567,128576,14,128581,128591,14,128641,128642,14,128646,128646,14,128648,128648,14,128650,128651,14,128653,128653,14,128655,128655,14,128657,128659,14,128661,128661,14,128663,128663,14,128665,128666,14,128674,128674,14,128676,128677,14,128679,128685,14,128690,128690,14,128694,128694,14,128697,128702,14,128704,128704,14,128710,128714,14,128716,128716,14,128720,128720,14,128723,128724,14,128726,128727,14,128733,128735,14,128742,128744,14,128746,128746,14,128749,128751,14,128753,128754,14,128756,128758,14,128761,128761,14,128763,128764,14,128884,128895,14,128992,129003,14,129008,129008,14,129036,129039,14,129114,129119,14,129198,129279,14,129293,129295,14,129305,129310,14,129312,129319,14,129328,129328,14,129331,129338,14,129343,129343,14,129351,129355,14,129357,129359,14,129375,129387,14,129393,129393,14,129395,129398,14,129401,129401,14,129403,129403,14,129408,129412,14,129426,129431,14,129443,129444,14,129451,129453,14,129456,129465,14,129472,129472,14,129475,129482,14,129484,129484,14,129488,129510,14,129536,129647,14,129652,129652,14,129656,129658,14,129661,129663,14,129667,129670,14,129680,129685,14,129705,129708,14,129712,129718,14,129723,129727,14,129731,129733,14,129744,129750,14,129754,129759,14,129768,129775,14,129783,129791,14,917504,917504,4,917506,917535,4,917632,917759,4,918000,921599,4,0,9,4,11,12,4,14,31,4,169,169,14,174,174,14,1155,1159,5,1425,1469,5,1473,1474,5,1479,1479,5,1552,1562,5,1611,1631,5,1750,1756,5,1759,1764,5,1770,1773,5,1809,1809,5,1958,1968,5,2045,2045,5,2075,2083,5,2089,2093,5,2192,2193,1,2250,2273,5,2275,2306,5,2362,2362,5,2364,2364,5,2369,2376,5,2381,2381,5,2385,2391,5,2433,2433,5,2492,2492,5,2495,2496,7,2503,2504,7,2509,2509,5,2530,2531,5,2561,2562,5,2620,2620,5,2625,2626,5,2635,2637,5,2672,2673,5,2689,2690,5,2748,2748,5,2753,2757,5,2761,2761,7,2765,2765,5,2810,2815,5,2818,2819,7,2878,2878,5,2880,2880,7,2887,2888,7,2893,2893,5,2903,2903,5,2946,2946,5,3007,3007,7,3009,3010,7,3018,3020,7,3031,3031,5,3073,3075,7,3132,3132,5,3137,3140,7,3146,3149,5,3170,3171,5,3202,3203,7,3262,3262,7,3264,3265,7,3267,3268,7,3271,3272,7,3276,3277,5,3298,3299,5,3330,3331,7,3390,3390,5,3393,3396,5,3402,3404,7,3406,3406,1,3426,3427,5,3458,3459,7,3535,3535,5,3538,3540,5,3544,3550,7,3570,3571,7,3635,3635,7,3655,3662,5,3763,3763,7,3784,3789,5,3893,3893,5,3897,3897,5,3953,3966,5,3968,3972,5,3981,3991,5,4038,4038,5,4145,4145,7,4153,4154,5,4157,4158,5,4184,4185,5,4209,4212,5,4228,4228,7,4237,4237,5,4352,4447,8,4520,4607,10,5906,5908,5,5938,5939,5,5970,5971,5,6068,6069,5,6071,6077,5,6086,6086,5,6089,6099,5,6155,6157,5,6159,6159,5,6313,6313,5,6435,6438,7,6441,6443,7,6450,6450,5,6457,6459,5,6681,6682,7,6741,6741,7,6743,6743,7,6752,6752,5,6757,6764,5,6771,6780,5,6832,6845,5,6847,6862,5,6916,6916,7,6965,6965,5,6971,6971,7,6973,6977,7,6979,6980,7,7040,7041,5,7073,7073,7,7078,7079,7,7082,7082,7,7142,7142,5,7144,7145,5,7149,7149,5,7151,7153,5,7204,7211,7,7220,7221,7,7376,7378,5,7393,7393,7,7405,7405,5,7415,7415,7,7616,7679,5,8204,8204,5,8206,8207,4,8233,8233,4,8252,8252,14,8288,8292,4,8294,8303,4,8413,8416,5,8418,8420,5,8482,8482,14,8596,8601,14,8986,8987,14,9096,9096,14,9193,9196,14,9199,9199,14,9201,9202,14,9208,9210,14,9642,9643,14,9664,9664,14,9728,9729,14,9732,9732,14,9735,9741,14,9743,9744,14,9746,9746,14,9750,9751,14,9753,9756,14,9758,9759,14,9761,9761,14,9764,9765,14,9767,9769,14,9771,9773,14,9775,9775,14,9784,9785,14,9787,9791,14,9793,9793,14,9795,9799,14,9812,9822,14,9824,9824,14,9827,9827,14,9829,9830,14,9832,9832,14,9851,9851,14,9854,9854,14,9856,9861,14,9874,9874,14,9876,9876,14,9878,9879,14,9881,9881,14,9883,9884,14,9888,9889,14,9895,9895,14,9898,9899,14,9904,9905,14,9917,9918,14,9924,9925,14,9928,9928,14,9934,9934,14,9936,9936,14,9938,9938,14,9940,9940,14,9961,9961,14,9963,9967,14,9970,9971,14,9973,9973,14,9975,9977,14,9979,9980,14,9982,9985,14,9987,9988,14,9992,9996,14,9998,9998,14,10000,10001,14,10004,10004,14,10013,10013,14,10024,10024,14,10052,10052,14,10060,10060,14,10067,10069,14,10083,10083,14,10085,10087,14,10145,10145,14,10175,10175,14,11013,11015,14,11088,11088,14,11503,11505,5,11744,11775,5,12334,12335,5,12349,12349,14,12951,12951,14,42607,42607,5,42612,42621,5,42736,42737,5,43014,43014,5,43043,43044,7,43047,43047,7,43136,43137,7,43204,43205,5,43263,43263,5,43335,43345,5,43360,43388,8,43395,43395,7,43444,43445,7,43450,43451,7,43454,43456,7,43561,43566,5,43569,43570,5,43573,43574,5,43596,43596,5,43644,43644,5,43698,43700,5,43710,43711,5,43755,43755,7,43758,43759,7,43766,43766,5,44005,44005,5,44008,44008,5,44012,44012,7,44032,44032,11,44060,44060,11,44088,44088,11,44116,44116,11,44144,44144,11,44172,44172,11,44200,44200,11,44228,44228,11,44256,44256,11,44284,44284,11,44312,44312,11,44340,44340,11,44368,44368,11,44396,44396,11,44424,44424,11,44452,44452,11,44480,44480,11,44508,44508,11,44536,44536,11,44564,44564,11,44592,44592,11,44620,44620,11,44648,44648,11,44676,44676,11,44704,44704,11,44732,44732,11,44760,44760,11,44788,44788,11,44816,44816,11,44844,44844,11,44872,44872,11,44900,44900,11,44928,44928,11,44956,44956,11,44984,44984,11,45012,45012,11,45040,45040,11,45068,45068,11,45096,45096,11,45124,45124,11,45152,45152,11,45180,45180,11,45208,45208,11,45236,45236,11,45264,45264,11,45292,45292,11,45320,45320,11,45348,45348,11,45376,45376,11,45404,45404,11,45432,45432,11,45460,45460,11,45488,45488,11,45516,45516,11,45544,45544,11,45572,45572,11,45600,45600,11,45628,45628,11,45656,45656,11,45684,45684,11,45712,45712,11,45740,45740,11,45768,45768,11,45796,45796,11,45824,45824,11,45852,45852,11,45880,45880,11,45908,45908,11,45936,45936,11,45964,45964,11,45992,45992,11,46020,46020,11,46048,46048,11,46076,46076,11,46104,46104,11,46132,46132,11,46160,46160,11,46188,46188,11,46216,46216,11,46244,46244,11,46272,46272,11,46300,46300,11,46328,46328,11,46356,46356,11,46384,46384,11,46412,46412,11,46440,46440,11,46468,46468,11,46496,46496,11,46524,46524,11,46552,46552,11,46580,46580,11,46608,46608,11,46636,46636,11,46664,46664,11,46692,46692,11,46720,46720,11,46748,46748,11,46776,46776,11,46804,46804,11,46832,46832,11,46860,46860,11,46888,46888,11,46916,46916,11,46944,46944,11,46972,46972,11,47000,47000,11,47028,47028,11,47056,47056,11,47084,47084,11,47112,47112,11,47140,47140,11,47168,47168,11,47196,47196,11,47224,47224,11,47252,47252,11,47280,47280,11,47308,47308,11,47336,47336,11,47364,47364,11,47392,47392,11,47420,47420,11,47448,47448,11,47476,47476,11,47504,47504,11,47532,47532,11,47560,47560,11,47588,47588,11,47616,47616,11,47644,47644,11,47672,47672,11,47700,47700,11,47728,47728,11,47756,47756,11,47784,47784,11,47812,47812,11,47840,47840,11,47868,47868,11,47896,47896,11,47924,47924,11,47952,47952,11,47980,47980,11,48008,48008,11,48036,48036,11,48064,48064,11,48092,48092,11,48120,48120,11,48148,48148,11,48176,48176,11,48204,48204,11,48232,48232,11,48260,48260,11,48288,48288,11,48316,48316,11,48344,48344,11,48372,48372,11,48400,48400,11,48428,48428,11,48456,48456,11,48484,48484,11,48512,48512,11,48540,48540,11,48568,48568,11,48596,48596,11,48624,48624,11,48652,48652,11,48680,48680,11,48708,48708,11,48736,48736,11,48764,48764,11,48792,48792,11,48820,48820,11,48848,48848,11,48876,48876,11,48904,48904,11,48932,48932,11,48960,48960,11,48988,48988,11,49016,49016,11,49044,49044,11,49072,49072,11,49100,49100,11,49128,49128,11,49156,49156,11,49184,49184,11,49212,49212,11,49240,49240,11,49268,49268,11,49296,49296,11,49324,49324,11,49352,49352,11,49380,49380,11,49408,49408,11,49436,49436,11,49464,49464,11,49492,49492,11,49520,49520,11,49548,49548,11,49576,49576,11,49604,49604,11,49632,49632,11,49660,49660,11,49688,49688,11,49716,49716,11,49744,49744,11,49772,49772,11,49800,49800,11,49828,49828,11,49856,49856,11,49884,49884,11,49912,49912,11,49940,49940,11,49968,49968,11,49996,49996,11,50024,50024,11,50052,50052,11,50080,50080,11,50108,50108,11,50136,50136,11,50164,50164,11,50192,50192,11,50220,50220,11,50248,50248,11,50276,50276,11,50304,50304,11,50332,50332,11,50360,50360,11,50388,50388,11,50416,50416,11,50444,50444,11,50472,50472,11,50500,50500,11,50528,50528,11,50556,50556,11,50584,50584,11,50612,50612,11,50640,50640,11,50668,50668,11,50696,50696,11,50724,50724,11,50752,50752,11,50780,50780,11,50808,50808,11,50836,50836,11,50864,50864,11,50892,50892,11,50920,50920,11,50948,50948,11,50976,50976,11,51004,51004,11,51032,51032,11,51060,51060,11,51088,51088,11,51116,51116,11,51144,51144,11,51172,51172,11,51200,51200,11,51228,51228,11,51256,51256,11,51284,51284,11,51312,51312,11,51340,51340,11,51368,51368,11,51396,51396,11,51424,51424,11,51452,51452,11,51480,51480,11,51508,51508,11,51536,51536,11,51564,51564,11,51592,51592,11,51620,51620,11,51648,51648,11,51676,51676,11,51704,51704,11,51732,51732,11,51760,51760,11,51788,51788,11,51816,51816,11,51844,51844,11,51872,51872,11,51900,51900,11,51928,51928,11,51956,51956,11,51984,51984,11,52012,52012,11,52040,52040,11,52068,52068,11,52096,52096,11,52124,52124,11,52152,52152,11,52180,52180,11,52208,52208,11,52236,52236,11,52264,52264,11,52292,52292,11,52320,52320,11,52348,52348,11,52376,52376,11,52404,52404,11,52432,52432,11,52460,52460,11,52488,52488,11,52516,52516,11,52544,52544,11,52572,52572,11,52600,52600,11,52628,52628,11,52656,52656,11,52684,52684,11,52712,52712,11,52740,52740,11,52768,52768,11,52796,52796,11,52824,52824,11,52852,52852,11,52880,52880,11,52908,52908,11,52936,52936,11,52964,52964,11,52992,52992,11,53020,53020,11,53048,53048,11,53076,53076,11,53104,53104,11,53132,53132,11,53160,53160,11,53188,53188,11,53216,53216,11,53244,53244,11,53272,53272,11,53300,53300,11,53328,53328,11,53356,53356,11,53384,53384,11,53412,53412,11,53440,53440,11,53468,53468,11,53496,53496,11,53524,53524,11,53552,53552,11,53580,53580,11,53608,53608,11,53636,53636,11,53664,53664,11,53692,53692,11,53720,53720,11,53748,53748,11,53776,53776,11,53804,53804,11,53832,53832,11,53860,53860,11,53888,53888,11,53916,53916,11,53944,53944,11,53972,53972,11,54000,54000,11,54028,54028,11,54056,54056,11,54084,54084,11,54112,54112,11,54140,54140,11,54168,54168,11,54196,54196,11,54224,54224,11,54252,54252,11,54280,54280,11,54308,54308,11,54336,54336,11,54364,54364,11,54392,54392,11,54420,54420,11,54448,54448,11,54476,54476,11,54504,54504,11,54532,54532,11,54560,54560,11,54588,54588,11,54616,54616,11,54644,54644,11,54672,54672,11,54700,54700,11,54728,54728,11,54756,54756,11,54784,54784,11,54812,54812,11,54840,54840,11,54868,54868,11,54896,54896,11,54924,54924,11,54952,54952,11,54980,54980,11,55008,55008,11,55036,55036,11,55064,55064,11,55092,55092,11,55120,55120,11,55148,55148,11,55176,55176,11,55216,55238,9,64286,64286,5,65056,65071,5,65438,65439,5,65529,65531,4,66272,66272,5,68097,68099,5,68108,68111,5,68159,68159,5,68900,68903,5,69446,69456,5,69632,69632,7,69634,69634,7,69744,69744,5,69759,69761,5,69808,69810,7,69815,69816,7,69821,69821,1,69837,69837,1,69927,69931,5,69933,69940,5,70003,70003,5,70018,70018,7,70070,70078,5,70082,70083,1,70094,70094,7,70188,70190,7,70194,70195,7,70197,70197,7,70206,70206,5,70368,70370,7,70400,70401,5,70459,70460,5,70463,70463,7,70465,70468,7,70475,70477,7,70498,70499,7,70512,70516,5,70712,70719,5,70722,70724,5,70726,70726,5,70832,70832,5,70835,70840,5,70842,70842,5,70845,70845,5,70847,70848,5,70850,70851,5,71088,71089,7,71096,71099,7,71102,71102,7,71132,71133,5,71219,71226,5,71229,71229,5,71231,71232,5,71340,71340,7,71342,71343,7,71350,71350,7,71453,71455,5,71462,71462,7,71724,71726,7,71736,71736,7,71984,71984,5,71991,71992,7,71997,71997,7,71999,71999,1,72001,72001,1,72003,72003,5,72148,72151,5,72156,72159,7,72164,72164,7,72243,72248,5,72250,72250,1,72263,72263,5,72279,72280,7,72324,72329,1,72343,72343,7,72751,72751,7,72760,72765,5,72767,72767,5,72873,72873,7,72881,72881,7,72884,72884,7,73009,73014,5,73020,73021,5,73030,73030,1,73098,73102,7,73107,73108,7,73110,73110,7,73459,73460,5,78896,78904,4,92976,92982,5,94033,94087,7,94180,94180,5,113821,113822,5,118528,118573,5,119141,119141,5,119143,119145,5,119150,119154,5,119163,119170,5,119210,119213,5,121344,121398,5,121461,121461,5,121499,121503,5,122880,122886,5,122907,122913,5,122918,122922,5,123566,123566,5,125136,125142,5,126976,126979,14,126981,127182,14,127184,127231,14,127279,127279,14,127344,127345,14,127374,127374,14,127405,127461,14,127489,127490,14,127514,127514,14,127538,127546,14,127561,127567,14,127570,127743,14,127757,127758,14,127760,127760,14,127762,127762,14,127766,127768,14,127770,127770,14,127772,127772,14,127775,127776,14,127778,127779,14,127789,127791,14,127794,127795,14,127798,127798,14,127819,127819,14,127824,127824,14,127868,127868,14,127870,127871,14,127892,127893,14,127896,127896,14,127900,127901,14,127904,127940,14,127942,127942,14,127944,127944,14,127946,127946,14,127951,127955,14,127968,127971,14,127973,127984,14,127987,127987,14,127989,127989,14,127991,127991,14,127995,127999,5,128008,128008,14,128012,128014,14,128017,128018,14,128020,128020,14,128022,128022,14,128042,128042,14,128063,128063,14,128065,128065,14,128101,128101,14,128108,128109,14,128173,128173,14,128182,128183,14,128236,128237,14,128239,128239,14,128245,128245,14,128248,128248,14,128253,128253,14,128255,128258,14,128260,128263,14,128265,128265,14,128277,128277,14,128300,128301,14,128326,128328,14,128331,128334,14,128336,128347,14,128360,128366,14,128369,128370,14,128378,128378,14,128391,128391,14,128394,128397,14,128400,128400,14,128405,128406,14,128420,128420,14,128422,128423,14,128425,128432,14,128435,128443,14,128445,128449,14,128453,128464,14,128468,128475,14,128479,128480,14,128482,128482,14,128484,128487,14,128489,128494,14,128496,128498,14,128500,128505,14,128507,128511,14,128513,128518,14,128521,128525,14,128527,128527,14,128529,128529,14,128533,128533,14,128535,128535,14,128537,128537,14]' - ) - } - function ce(P, O) { - if (P === 0) return 0 - const V = Se(P, O) - if (V !== void 0) return V - const j = new T(O, P) - return j.prevCodePoint(), j.offset - } - n.getLeftDeleteOffset = ce - function Se(P, O) { - const V = new T(O, P) - let j = V.prevCodePoint() - for (; we(j) || j === 65039 || j === 8419; ) { - if (V.offset === 0) return - j = V.prevCodePoint() - } - if (!J(j)) return - let Z = V.offset - return Z > 0 && V.prevCodePoint() === 8205 && (Z = V.offset), Z - } - function we(P) { - return 127995 <= P && P <= 127999 - } - n.noBreakWhitespace = '\xA0' - class fe { - static getInstance(O) { - return fe.cache.get(Array.from(O)) - } - static getLocales() { - return fe._locales.value - } - constructor(O) { - this.confusableDictionary = O - } - isAmbiguous(O) { - return this.confusableDictionary.has(O) - } - getPrimaryConfusable(O) { - return this.confusableDictionary.get(O) - } - getConfusableCodePoints() { - return new Set(this.confusableDictionary.keys()) - } - } - ;(i = fe), - (fe.ambiguousCharacterData = new D.Lazy(() => - JSON.parse( - '{"_common":[8232,32,8233,32,5760,32,8192,32,8193,32,8194,32,8195,32,8196,32,8197,32,8198,32,8200,32,8201,32,8202,32,8287,32,8199,32,8239,32,2042,95,65101,95,65102,95,65103,95,8208,45,8209,45,8210,45,65112,45,1748,45,8259,45,727,45,8722,45,10134,45,11450,45,1549,44,1643,44,8218,44,184,44,42233,44,894,59,2307,58,2691,58,1417,58,1795,58,1796,58,5868,58,65072,58,6147,58,6153,58,8282,58,1475,58,760,58,42889,58,8758,58,720,58,42237,58,451,33,11601,33,660,63,577,63,2429,63,5038,63,42731,63,119149,46,8228,46,1793,46,1794,46,42510,46,68176,46,1632,46,1776,46,42232,46,1373,96,65287,96,8219,96,8242,96,1370,96,1523,96,8175,96,65344,96,900,96,8189,96,8125,96,8127,96,8190,96,697,96,884,96,712,96,714,96,715,96,756,96,699,96,701,96,700,96,702,96,42892,96,1497,96,2036,96,2037,96,5194,96,5836,96,94033,96,94034,96,65339,91,10088,40,10098,40,12308,40,64830,40,65341,93,10089,41,10099,41,12309,41,64831,41,10100,123,119060,123,10101,125,65342,94,8270,42,1645,42,8727,42,66335,42,5941,47,8257,47,8725,47,8260,47,9585,47,10187,47,10744,47,119354,47,12755,47,12339,47,11462,47,20031,47,12035,47,65340,92,65128,92,8726,92,10189,92,10741,92,10745,92,119311,92,119355,92,12756,92,20022,92,12034,92,42872,38,708,94,710,94,5869,43,10133,43,66203,43,8249,60,10094,60,706,60,119350,60,5176,60,5810,60,5120,61,11840,61,12448,61,42239,61,8250,62,10095,62,707,62,119351,62,5171,62,94015,62,8275,126,732,126,8128,126,8764,126,65372,124,65293,45,120784,50,120794,50,120804,50,120814,50,120824,50,130034,50,42842,50,423,50,1000,50,42564,50,5311,50,42735,50,119302,51,120785,51,120795,51,120805,51,120815,51,120825,51,130035,51,42923,51,540,51,439,51,42858,51,11468,51,1248,51,94011,51,71882,51,120786,52,120796,52,120806,52,120816,52,120826,52,130036,52,5070,52,71855,52,120787,53,120797,53,120807,53,120817,53,120827,53,130037,53,444,53,71867,53,120788,54,120798,54,120808,54,120818,54,120828,54,130038,54,11474,54,5102,54,71893,54,119314,55,120789,55,120799,55,120809,55,120819,55,120829,55,130039,55,66770,55,71878,55,2819,56,2538,56,2666,56,125131,56,120790,56,120800,56,120810,56,120820,56,120830,56,130040,56,547,56,546,56,66330,56,2663,57,2920,57,2541,57,3437,57,120791,57,120801,57,120811,57,120821,57,120831,57,130041,57,42862,57,11466,57,71884,57,71852,57,71894,57,9082,97,65345,97,119834,97,119886,97,119938,97,119990,97,120042,97,120094,97,120146,97,120198,97,120250,97,120302,97,120354,97,120406,97,120458,97,593,97,945,97,120514,97,120572,97,120630,97,120688,97,120746,97,65313,65,119808,65,119860,65,119912,65,119964,65,120016,65,120068,65,120120,65,120172,65,120224,65,120276,65,120328,65,120380,65,120432,65,913,65,120488,65,120546,65,120604,65,120662,65,120720,65,5034,65,5573,65,42222,65,94016,65,66208,65,119835,98,119887,98,119939,98,119991,98,120043,98,120095,98,120147,98,120199,98,120251,98,120303,98,120355,98,120407,98,120459,98,388,98,5071,98,5234,98,5551,98,65314,66,8492,66,119809,66,119861,66,119913,66,120017,66,120069,66,120121,66,120173,66,120225,66,120277,66,120329,66,120381,66,120433,66,42932,66,914,66,120489,66,120547,66,120605,66,120663,66,120721,66,5108,66,5623,66,42192,66,66178,66,66209,66,66305,66,65347,99,8573,99,119836,99,119888,99,119940,99,119992,99,120044,99,120096,99,120148,99,120200,99,120252,99,120304,99,120356,99,120408,99,120460,99,7428,99,1010,99,11429,99,43951,99,66621,99,128844,67,71922,67,71913,67,65315,67,8557,67,8450,67,8493,67,119810,67,119862,67,119914,67,119966,67,120018,67,120174,67,120226,67,120278,67,120330,67,120382,67,120434,67,1017,67,11428,67,5087,67,42202,67,66210,67,66306,67,66581,67,66844,67,8574,100,8518,100,119837,100,119889,100,119941,100,119993,100,120045,100,120097,100,120149,100,120201,100,120253,100,120305,100,120357,100,120409,100,120461,100,1281,100,5095,100,5231,100,42194,100,8558,68,8517,68,119811,68,119863,68,119915,68,119967,68,120019,68,120071,68,120123,68,120175,68,120227,68,120279,68,120331,68,120383,68,120435,68,5024,68,5598,68,5610,68,42195,68,8494,101,65349,101,8495,101,8519,101,119838,101,119890,101,119942,101,120046,101,120098,101,120150,101,120202,101,120254,101,120306,101,120358,101,120410,101,120462,101,43826,101,1213,101,8959,69,65317,69,8496,69,119812,69,119864,69,119916,69,120020,69,120072,69,120124,69,120176,69,120228,69,120280,69,120332,69,120384,69,120436,69,917,69,120492,69,120550,69,120608,69,120666,69,120724,69,11577,69,5036,69,42224,69,71846,69,71854,69,66182,69,119839,102,119891,102,119943,102,119995,102,120047,102,120099,102,120151,102,120203,102,120255,102,120307,102,120359,102,120411,102,120463,102,43829,102,42905,102,383,102,7837,102,1412,102,119315,70,8497,70,119813,70,119865,70,119917,70,120021,70,120073,70,120125,70,120177,70,120229,70,120281,70,120333,70,120385,70,120437,70,42904,70,988,70,120778,70,5556,70,42205,70,71874,70,71842,70,66183,70,66213,70,66853,70,65351,103,8458,103,119840,103,119892,103,119944,103,120048,103,120100,103,120152,103,120204,103,120256,103,120308,103,120360,103,120412,103,120464,103,609,103,7555,103,397,103,1409,103,119814,71,119866,71,119918,71,119970,71,120022,71,120074,71,120126,71,120178,71,120230,71,120282,71,120334,71,120386,71,120438,71,1292,71,5056,71,5107,71,42198,71,65352,104,8462,104,119841,104,119945,104,119997,104,120049,104,120101,104,120153,104,120205,104,120257,104,120309,104,120361,104,120413,104,120465,104,1211,104,1392,104,5058,104,65320,72,8459,72,8460,72,8461,72,119815,72,119867,72,119919,72,120023,72,120179,72,120231,72,120283,72,120335,72,120387,72,120439,72,919,72,120494,72,120552,72,120610,72,120668,72,120726,72,11406,72,5051,72,5500,72,42215,72,66255,72,731,105,9075,105,65353,105,8560,105,8505,105,8520,105,119842,105,119894,105,119946,105,119998,105,120050,105,120102,105,120154,105,120206,105,120258,105,120310,105,120362,105,120414,105,120466,105,120484,105,618,105,617,105,953,105,8126,105,890,105,120522,105,120580,105,120638,105,120696,105,120754,105,1110,105,42567,105,1231,105,43893,105,5029,105,71875,105,65354,106,8521,106,119843,106,119895,106,119947,106,119999,106,120051,106,120103,106,120155,106,120207,106,120259,106,120311,106,120363,106,120415,106,120467,106,1011,106,1112,106,65322,74,119817,74,119869,74,119921,74,119973,74,120025,74,120077,74,120129,74,120181,74,120233,74,120285,74,120337,74,120389,74,120441,74,42930,74,895,74,1032,74,5035,74,5261,74,42201,74,119844,107,119896,107,119948,107,120000,107,120052,107,120104,107,120156,107,120208,107,120260,107,120312,107,120364,107,120416,107,120468,107,8490,75,65323,75,119818,75,119870,75,119922,75,119974,75,120026,75,120078,75,120130,75,120182,75,120234,75,120286,75,120338,75,120390,75,120442,75,922,75,120497,75,120555,75,120613,75,120671,75,120729,75,11412,75,5094,75,5845,75,42199,75,66840,75,1472,108,8739,73,9213,73,65512,73,1633,108,1777,73,66336,108,125127,108,120783,73,120793,73,120803,73,120813,73,120823,73,130033,73,65321,73,8544,73,8464,73,8465,73,119816,73,119868,73,119920,73,120024,73,120128,73,120180,73,120232,73,120284,73,120336,73,120388,73,120440,73,65356,108,8572,73,8467,108,119845,108,119897,108,119949,108,120001,108,120053,108,120105,73,120157,73,120209,73,120261,73,120313,73,120365,73,120417,73,120469,73,448,73,120496,73,120554,73,120612,73,120670,73,120728,73,11410,73,1030,73,1216,73,1493,108,1503,108,1575,108,126464,108,126592,108,65166,108,65165,108,1994,108,11599,73,5825,73,42226,73,93992,73,66186,124,66313,124,119338,76,8556,76,8466,76,119819,76,119871,76,119923,76,120027,76,120079,76,120131,76,120183,76,120235,76,120287,76,120339,76,120391,76,120443,76,11472,76,5086,76,5290,76,42209,76,93974,76,71843,76,71858,76,66587,76,66854,76,65325,77,8559,77,8499,77,119820,77,119872,77,119924,77,120028,77,120080,77,120132,77,120184,77,120236,77,120288,77,120340,77,120392,77,120444,77,924,77,120499,77,120557,77,120615,77,120673,77,120731,77,1018,77,11416,77,5047,77,5616,77,5846,77,42207,77,66224,77,66321,77,119847,110,119899,110,119951,110,120003,110,120055,110,120107,110,120159,110,120211,110,120263,110,120315,110,120367,110,120419,110,120471,110,1400,110,1404,110,65326,78,8469,78,119821,78,119873,78,119925,78,119977,78,120029,78,120081,78,120185,78,120237,78,120289,78,120341,78,120393,78,120445,78,925,78,120500,78,120558,78,120616,78,120674,78,120732,78,11418,78,42208,78,66835,78,3074,111,3202,111,3330,111,3458,111,2406,111,2662,111,2790,111,3046,111,3174,111,3302,111,3430,111,3664,111,3792,111,4160,111,1637,111,1781,111,65359,111,8500,111,119848,111,119900,111,119952,111,120056,111,120108,111,120160,111,120212,111,120264,111,120316,111,120368,111,120420,111,120472,111,7439,111,7441,111,43837,111,959,111,120528,111,120586,111,120644,111,120702,111,120760,111,963,111,120532,111,120590,111,120648,111,120706,111,120764,111,11423,111,4351,111,1413,111,1505,111,1607,111,126500,111,126564,111,126596,111,65259,111,65260,111,65258,111,65257,111,1726,111,64428,111,64429,111,64427,111,64426,111,1729,111,64424,111,64425,111,64423,111,64422,111,1749,111,3360,111,4125,111,66794,111,71880,111,71895,111,66604,111,1984,79,2534,79,2918,79,12295,79,70864,79,71904,79,120782,79,120792,79,120802,79,120812,79,120822,79,130032,79,65327,79,119822,79,119874,79,119926,79,119978,79,120030,79,120082,79,120134,79,120186,79,120238,79,120290,79,120342,79,120394,79,120446,79,927,79,120502,79,120560,79,120618,79,120676,79,120734,79,11422,79,1365,79,11604,79,4816,79,2848,79,66754,79,42227,79,71861,79,66194,79,66219,79,66564,79,66838,79,9076,112,65360,112,119849,112,119901,112,119953,112,120005,112,120057,112,120109,112,120161,112,120213,112,120265,112,120317,112,120369,112,120421,112,120473,112,961,112,120530,112,120544,112,120588,112,120602,112,120646,112,120660,112,120704,112,120718,112,120762,112,120776,112,11427,112,65328,80,8473,80,119823,80,119875,80,119927,80,119979,80,120031,80,120083,80,120187,80,120239,80,120291,80,120343,80,120395,80,120447,80,929,80,120504,80,120562,80,120620,80,120678,80,120736,80,11426,80,5090,80,5229,80,42193,80,66197,80,119850,113,119902,113,119954,113,120006,113,120058,113,120110,113,120162,113,120214,113,120266,113,120318,113,120370,113,120422,113,120474,113,1307,113,1379,113,1382,113,8474,81,119824,81,119876,81,119928,81,119980,81,120032,81,120084,81,120188,81,120240,81,120292,81,120344,81,120396,81,120448,81,11605,81,119851,114,119903,114,119955,114,120007,114,120059,114,120111,114,120163,114,120215,114,120267,114,120319,114,120371,114,120423,114,120475,114,43847,114,43848,114,7462,114,11397,114,43905,114,119318,82,8475,82,8476,82,8477,82,119825,82,119877,82,119929,82,120033,82,120189,82,120241,82,120293,82,120345,82,120397,82,120449,82,422,82,5025,82,5074,82,66740,82,5511,82,42211,82,94005,82,65363,115,119852,115,119904,115,119956,115,120008,115,120060,115,120112,115,120164,115,120216,115,120268,115,120320,115,120372,115,120424,115,120476,115,42801,115,445,115,1109,115,43946,115,71873,115,66632,115,65331,83,119826,83,119878,83,119930,83,119982,83,120034,83,120086,83,120138,83,120190,83,120242,83,120294,83,120346,83,120398,83,120450,83,1029,83,1359,83,5077,83,5082,83,42210,83,94010,83,66198,83,66592,83,119853,116,119905,116,119957,116,120009,116,120061,116,120113,116,120165,116,120217,116,120269,116,120321,116,120373,116,120425,116,120477,116,8868,84,10201,84,128872,84,65332,84,119827,84,119879,84,119931,84,119983,84,120035,84,120087,84,120139,84,120191,84,120243,84,120295,84,120347,84,120399,84,120451,84,932,84,120507,84,120565,84,120623,84,120681,84,120739,84,11430,84,5026,84,42196,84,93962,84,71868,84,66199,84,66225,84,66325,84,119854,117,119906,117,119958,117,120010,117,120062,117,120114,117,120166,117,120218,117,120270,117,120322,117,120374,117,120426,117,120478,117,42911,117,7452,117,43854,117,43858,117,651,117,965,117,120534,117,120592,117,120650,117,120708,117,120766,117,1405,117,66806,117,71896,117,8746,85,8899,85,119828,85,119880,85,119932,85,119984,85,120036,85,120088,85,120140,85,120192,85,120244,85,120296,85,120348,85,120400,85,120452,85,1357,85,4608,85,66766,85,5196,85,42228,85,94018,85,71864,85,8744,118,8897,118,65366,118,8564,118,119855,118,119907,118,119959,118,120011,118,120063,118,120115,118,120167,118,120219,118,120271,118,120323,118,120375,118,120427,118,120479,118,7456,118,957,118,120526,118,120584,118,120642,118,120700,118,120758,118,1141,118,1496,118,71430,118,43945,118,71872,118,119309,86,1639,86,1783,86,8548,86,119829,86,119881,86,119933,86,119985,86,120037,86,120089,86,120141,86,120193,86,120245,86,120297,86,120349,86,120401,86,120453,86,1140,86,11576,86,5081,86,5167,86,42719,86,42214,86,93960,86,71840,86,66845,86,623,119,119856,119,119908,119,119960,119,120012,119,120064,119,120116,119,120168,119,120220,119,120272,119,120324,119,120376,119,120428,119,120480,119,7457,119,1121,119,1309,119,1377,119,71434,119,71438,119,71439,119,43907,119,71919,87,71910,87,119830,87,119882,87,119934,87,119986,87,120038,87,120090,87,120142,87,120194,87,120246,87,120298,87,120350,87,120402,87,120454,87,1308,87,5043,87,5076,87,42218,87,5742,120,10539,120,10540,120,10799,120,65368,120,8569,120,119857,120,119909,120,119961,120,120013,120,120065,120,120117,120,120169,120,120221,120,120273,120,120325,120,120377,120,120429,120,120481,120,5441,120,5501,120,5741,88,9587,88,66338,88,71916,88,65336,88,8553,88,119831,88,119883,88,119935,88,119987,88,120039,88,120091,88,120143,88,120195,88,120247,88,120299,88,120351,88,120403,88,120455,88,42931,88,935,88,120510,88,120568,88,120626,88,120684,88,120742,88,11436,88,11613,88,5815,88,42219,88,66192,88,66228,88,66327,88,66855,88,611,121,7564,121,65369,121,119858,121,119910,121,119962,121,120014,121,120066,121,120118,121,120170,121,120222,121,120274,121,120326,121,120378,121,120430,121,120482,121,655,121,7935,121,43866,121,947,121,8509,121,120516,121,120574,121,120632,121,120690,121,120748,121,1199,121,4327,121,71900,121,65337,89,119832,89,119884,89,119936,89,119988,89,120040,89,120092,89,120144,89,120196,89,120248,89,120300,89,120352,89,120404,89,120456,89,933,89,978,89,120508,89,120566,89,120624,89,120682,89,120740,89,11432,89,1198,89,5033,89,5053,89,42220,89,94019,89,71844,89,66226,89,119859,122,119911,122,119963,122,120015,122,120067,122,120119,122,120171,122,120223,122,120275,122,120327,122,120379,122,120431,122,120483,122,7458,122,43923,122,71876,122,66293,90,71909,90,65338,90,8484,90,8488,90,119833,90,119885,90,119937,90,119989,90,120041,90,120197,90,120249,90,120301,90,120353,90,120405,90,120457,90,918,90,120493,90,120551,90,120609,90,120667,90,120725,90,5059,90,42204,90,71849,90,65282,34,65284,36,65285,37,65286,38,65290,42,65291,43,65294,46,65295,47,65296,48,65297,49,65298,50,65299,51,65300,52,65301,53,65302,54,65303,55,65304,56,65305,57,65308,60,65309,61,65310,62,65312,64,65316,68,65318,70,65319,71,65324,76,65329,81,65330,82,65333,85,65334,86,65335,87,65343,95,65346,98,65348,100,65350,102,65355,107,65357,109,65358,110,65361,113,65362,114,65364,116,65365,117,65367,119,65370,122,65371,123,65373,125,119846,109],"_default":[160,32,8211,45,65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"cs":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"de":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"es":[8211,45,65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"fr":[65374,126,65306,58,65281,33,8216,96,8245,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"it":[160,32,8211,45,65374,126,65306,58,65281,33,8216,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"ja":[8211,45,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65292,44,65307,59],"ko":[8211,45,65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"pl":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"pt-BR":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"qps-ploc":[160,32,8211,45,65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"ru":[65374,126,65306,58,65281,33,8216,96,8217,96,8245,96,180,96,12494,47,305,105,921,73,1009,112,215,120,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"tr":[160,32,8211,45,65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65288,40,65289,41,65292,44,65307,59,65311,63],"zh-hans":[65374,126,65306,58,65281,33,8245,96,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65288,40,65289,41],"zh-hant":[8211,45,65374,126,180,96,12494,47,1047,51,1073,54,1072,97,1040,65,1068,98,1042,66,1089,99,1057,67,1077,101,1045,69,1053,72,305,105,1050,75,921,73,1052,77,1086,111,1054,79,1009,112,1088,112,1056,80,1075,114,1058,84,215,120,1093,120,1061,88,1091,121,1059,89,65283,35,65307,59]}' - ) - )), - (fe.cache = new R.LRUCachedFunction((P) => { - function O(oe) { - const _e = new Map() - for (let pe = 0; pe < oe.length; pe += 2) _e.set(oe[pe], oe[pe + 1]) - return _e - } - function V(oe, _e) { - const pe = new Map(oe) - for (const [Ae, Ne] of _e) pe.set(Ae, Ne) - return pe - } - function j(oe, _e) { - if (!oe) return _e - const pe = new Map() - for (const [Ae, Ne] of oe) _e.has(Ae) && pe.set(Ae, Ne) - return pe - } - const Z = i.ambiguousCharacterData.value - let le = P.filter((oe) => !oe.startsWith('_') && oe in Z) - le.length === 0 && (le = ['_default']) - let he - for (const oe of le) { - const _e = O(Z[oe]) - he = j(he, _e) - } - const ye = O(Z._common), - ve = V(ye, he) - return new fe(ve) - })), - (fe._locales = new D.Lazy(() => - Object.keys(fe.ambiguousCharacterData.value).filter((P) => !P.startsWith('_')) - )), - (n.AmbiguousCharacters = fe) - class me { - static getRawData() { - return JSON.parse( - '[9,10,11,12,13,32,127,160,173,847,1564,4447,4448,6068,6069,6155,6156,6157,6158,7355,7356,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8203,8204,8205,8206,8207,8234,8235,8236,8237,8238,8239,8287,8288,8289,8290,8291,8292,8293,8294,8295,8296,8297,8298,8299,8300,8301,8302,8303,10240,12288,12644,65024,65025,65026,65027,65028,65029,65030,65031,65032,65033,65034,65035,65036,65037,65038,65039,65279,65440,65520,65521,65522,65523,65524,65525,65526,65527,65528,65532,78844,119155,119156,119157,119158,119159,119160,119161,119162,917504,917505,917506,917507,917508,917509,917510,917511,917512,917513,917514,917515,917516,917517,917518,917519,917520,917521,917522,917523,917524,917525,917526,917527,917528,917529,917530,917531,917532,917533,917534,917535,917536,917537,917538,917539,917540,917541,917542,917543,917544,917545,917546,917547,917548,917549,917550,917551,917552,917553,917554,917555,917556,917557,917558,917559,917560,917561,917562,917563,917564,917565,917566,917567,917568,917569,917570,917571,917572,917573,917574,917575,917576,917577,917578,917579,917580,917581,917582,917583,917584,917585,917586,917587,917588,917589,917590,917591,917592,917593,917594,917595,917596,917597,917598,917599,917600,917601,917602,917603,917604,917605,917606,917607,917608,917609,917610,917611,917612,917613,917614,917615,917616,917617,917618,917619,917620,917621,917622,917623,917624,917625,917626,917627,917628,917629,917630,917631,917760,917761,917762,917763,917764,917765,917766,917767,917768,917769,917770,917771,917772,917773,917774,917775,917776,917777,917778,917779,917780,917781,917782,917783,917784,917785,917786,917787,917788,917789,917790,917791,917792,917793,917794,917795,917796,917797,917798,917799,917800,917801,917802,917803,917804,917805,917806,917807,917808,917809,917810,917811,917812,917813,917814,917815,917816,917817,917818,917819,917820,917821,917822,917823,917824,917825,917826,917827,917828,917829,917830,917831,917832,917833,917834,917835,917836,917837,917838,917839,917840,917841,917842,917843,917844,917845,917846,917847,917848,917849,917850,917851,917852,917853,917854,917855,917856,917857,917858,917859,917860,917861,917862,917863,917864,917865,917866,917867,917868,917869,917870,917871,917872,917873,917874,917875,917876,917877,917878,917879,917880,917881,917882,917883,917884,917885,917886,917887,917888,917889,917890,917891,917892,917893,917894,917895,917896,917897,917898,917899,917900,917901,917902,917903,917904,917905,917906,917907,917908,917909,917910,917911,917912,917913,917914,917915,917916,917917,917918,917919,917920,917921,917922,917923,917924,917925,917926,917927,917928,917929,917930,917931,917932,917933,917934,917935,917936,917937,917938,917939,917940,917941,917942,917943,917944,917945,917946,917947,917948,917949,917950,917951,917952,917953,917954,917955,917956,917957,917958,917959,917960,917961,917962,917963,917964,917965,917966,917967,917968,917969,917970,917971,917972,917973,917974,917975,917976,917977,917978,917979,917980,917981,917982,917983,917984,917985,917986,917987,917988,917989,917990,917991,917992,917993,917994,917995,917996,917997,917998,917999]' - ) - } - static getData() { - return this._data || (this._data = new Set(me.getRawData())), this._data - } - static isInvisibleCharacter(O) { - return me.getData().has(O) - } - static get codePoints() { - return me.getData() - } - } - ;(me._data = void 0), (n.InvisibleCharacters = me) - }), - Q(Y[31], X([0, 1, 5]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.StringSHA1 = n.toHexString = n.stringHash = n.numberHash = n.doHash = n.hash = void 0) - function D(g) { - return i(g, 0) - } - n.hash = D - function i(g, S) { - switch (typeof g) { - case 'object': - return g === null ? s(349, S) : Array.isArray(g) ? h(g, S) : a(g, S) - case 'string': - return L(g, S) - case 'boolean': - return p(g, S) - case 'number': - return s(g, S) - case 'undefined': - return s(937, S) - default: - return s(617, S) - } - } - n.doHash = i - function s(g, S) { - return ((S << 5) - S + g) | 0 - } - n.numberHash = s - function p(g, S) { - return s(g ? 433 : 863, S) - } - function L(g, S) { - S = s(149417, S) - for (let E = 0, y = g.length; E < y; E++) S = s(g.charCodeAt(E), S) - return S - } - n.stringHash = L - function h(g, S) { - return (S = s(104579, S)), g.reduce((E, y) => i(y, E), S) - } - function a(g, S) { - return ( - (S = s(181387, S)), - Object.keys(g) - .sort() - .reduce((E, y) => ((E = L(y, E)), i(g[y], E)), S) - ) - } - function w(g, S, E = 32) { - const y = E - S, - _ = ~((1 << y) - 1) - return ((g << S) | ((_ & g) >>> y)) >>> 0 - } - function e(g, S = 0, E = g.byteLength, y = 0) { - for (let _ = 0; _ < E; _++) g[S + _] = y - } - function b(g, S, E = '0') { - for (; g.length < S; ) g = E + g - return g - } - function f(g, S = 32) { - return g instanceof ArrayBuffer - ? Array.from(new Uint8Array(g)) - .map((E) => E.toString(16).padStart(2, '0')) - .join('') - : b((g >>> 0).toString(16), S / 4) - } - n.toHexString = f - class v { - constructor() { - ;(this._h0 = 1732584193), - (this._h1 = 4023233417), - (this._h2 = 2562383102), - (this._h3 = 271733878), - (this._h4 = 3285377520), - (this._buff = new Uint8Array(64 + 3)), - (this._buffDV = new DataView(this._buff.buffer)), - (this._buffLen = 0), - (this._totalLen = 0), - (this._leftoverHighSurrogate = 0), - (this._finished = !1) - } - update(S) { - const E = S.length - if (E === 0) return - const y = this._buff - let _ = this._buffLen, - d = this._leftoverHighSurrogate, - C, - r - for (d !== 0 ? ((C = d), (r = -1), (d = 0)) : ((C = S.charCodeAt(0)), (r = 0)); ; ) { - let u = C - if (R.isHighSurrogate(C)) - if (r + 1 < E) { - const o = S.charCodeAt(r + 1) - R.isLowSurrogate(o) ? (r++, (u = R.computeCodePoint(C, o))) : (u = 65533) - } else { - d = C - break - } - else R.isLowSurrogate(C) && (u = 65533) - if (((_ = this._push(y, _, u)), r++, r < E)) C = S.charCodeAt(r) - else break - } - ;(this._buffLen = _), (this._leftoverHighSurrogate = d) - } - _push(S, E, y) { - return ( - y < 128 - ? (S[E++] = y) - : y < 2048 - ? ((S[E++] = 192 | ((y & 1984) >>> 6)), (S[E++] = 128 | ((y & 63) >>> 0))) - : y < 65536 - ? ((S[E++] = 224 | ((y & 61440) >>> 12)), - (S[E++] = 128 | ((y & 4032) >>> 6)), - (S[E++] = 128 | ((y & 63) >>> 0))) - : ((S[E++] = 240 | ((y & 1835008) >>> 18)), - (S[E++] = 128 | ((y & 258048) >>> 12)), - (S[E++] = 128 | ((y & 4032) >>> 6)), - (S[E++] = 128 | ((y & 63) >>> 0))), - E >= 64 && - (this._step(), - (E -= 64), - (this._totalLen += 64), - (S[0] = S[64 + 0]), - (S[1] = S[64 + 1]), - (S[2] = S[64 + 2])), - E - ) - } - digest() { - return ( - this._finished || - ((this._finished = !0), - this._leftoverHighSurrogate && - ((this._leftoverHighSurrogate = 0), - (this._buffLen = this._push(this._buff, this._buffLen, 65533))), - (this._totalLen += this._buffLen), - this._wrapUp()), - f(this._h0) + f(this._h1) + f(this._h2) + f(this._h3) + f(this._h4) - ) - } - _wrapUp() { - ;(this._buff[this._buffLen++] = 128), - e(this._buff, this._buffLen), - this._buffLen > 56 && (this._step(), e(this._buff)) - const S = 8 * this._totalLen - this._buffDV.setUint32(56, Math.floor(S / 4294967296), !1), - this._buffDV.setUint32(60, S % 4294967296, !1), - this._step() - } - _step() { - const S = v._bigBlock32, - E = this._buffDV - for (let l = 0; l < 64; l += 4) S.setUint32(l, E.getUint32(l, !1), !1) - for (let l = 64; l < 320; l += 4) - S.setUint32( - l, - w( - S.getUint32(l - 12, !1) ^ - S.getUint32(l - 32, !1) ^ - S.getUint32(l - 56, !1) ^ - S.getUint32(l - 64, !1), - 1 - ), - !1 - ) - let y = this._h0, - _ = this._h1, - d = this._h2, - C = this._h3, - r = this._h4, - u, - o, - c - for (let l = 0; l < 80; l++) - l < 20 - ? ((u = (_ & d) | (~_ & C)), (o = 1518500249)) - : l < 40 - ? ((u = _ ^ d ^ C), (o = 1859775393)) - : l < 60 - ? ((u = (_ & d) | (_ & C) | (d & C)), (o = 2400959708)) - : ((u = _ ^ d ^ C), (o = 3395469782)), - (c = (w(y, 5) + u + r + o + S.getUint32(l * 4, !1)) & 4294967295), - (r = C), - (C = d), - (d = w(_, 30)), - (_ = y), - (y = c) - ;(this._h0 = (this._h0 + y) & 4294967295), - (this._h1 = (this._h1 + _) & 4294967295), - (this._h2 = (this._h2 + d) & 4294967295), - (this._h3 = (this._h3 + C) & 4294967295), - (this._h4 = (this._h4 + r) & 4294967295) - } - } - ;(v._bigBlock32 = new DataView(new ArrayBuffer(320))), (n.StringSHA1 = v) - }), - Q(Y[17], X([0, 1, 28, 31]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.LcsDiff = n.stringDiff = n.StringDiffSequence = void 0) - class i { - constructor(e) { - this.source = e - } - getElements() { - const e = this.source, - b = new Int32Array(e.length) - for (let f = 0, v = e.length; f < v; f++) b[f] = e.charCodeAt(f) - return b - } - } - n.StringDiffSequence = i - function s(w, e, b) { - return new a(new i(w), new i(e)).ComputeDiff(b).changes - } - n.stringDiff = s - class p { - static Assert(e, b) { - if (!e) throw new Error(b) - } - } - class L { - static Copy(e, b, f, v, g) { - for (let S = 0; S < g; S++) f[v + S] = e[b + S] - } - static Copy2(e, b, f, v, g) { - for (let S = 0; S < g; S++) f[v + S] = e[b + S] - } - } - class h { - constructor() { - ;(this.m_changes = []), - (this.m_originalStart = 1073741824), - (this.m_modifiedStart = 1073741824), - (this.m_originalCount = 0), - (this.m_modifiedCount = 0) - } - MarkNextChange() { - ;(this.m_originalCount > 0 || this.m_modifiedCount > 0) && - this.m_changes.push( - new R.DiffChange( - this.m_originalStart, - this.m_originalCount, - this.m_modifiedStart, - this.m_modifiedCount - ) - ), - (this.m_originalCount = 0), - (this.m_modifiedCount = 0), - (this.m_originalStart = 1073741824), - (this.m_modifiedStart = 1073741824) - } - AddOriginalElement(e, b) { - ;(this.m_originalStart = Math.min(this.m_originalStart, e)), - (this.m_modifiedStart = Math.min(this.m_modifiedStart, b)), - this.m_originalCount++ - } - AddModifiedElement(e, b) { - ;(this.m_originalStart = Math.min(this.m_originalStart, e)), - (this.m_modifiedStart = Math.min(this.m_modifiedStart, b)), - this.m_modifiedCount++ - } - getChanges() { - return ( - (this.m_originalCount > 0 || this.m_modifiedCount > 0) && this.MarkNextChange(), - this.m_changes - ) - } - getReverseChanges() { - return ( - (this.m_originalCount > 0 || this.m_modifiedCount > 0) && this.MarkNextChange(), - this.m_changes.reverse(), - this.m_changes - ) - } - } - class a { - constructor(e, b, f = null) { - ;(this.ContinueProcessingPredicate = f), - (this._originalSequence = e), - (this._modifiedSequence = b) - const [v, g, S] = a._getElements(e), - [E, y, _] = a._getElements(b) - ;(this._hasStrings = S && _), - (this._originalStringElements = v), - (this._originalElementsOrHash = g), - (this._modifiedStringElements = E), - (this._modifiedElementsOrHash = y), - (this.m_forwardHistory = []), - (this.m_reverseHistory = []) - } - static _isStringArray(e) { - return e.length > 0 && typeof e[0] == 'string' - } - static _getElements(e) { - const b = e.getElements() - if (a._isStringArray(b)) { - const f = new Int32Array(b.length) - for (let v = 0, g = b.length; v < g; v++) f[v] = (0, D.stringHash)(b[v], 0) - return [b, f, !0] - } - return b instanceof Int32Array ? [[], b, !1] : [[], new Int32Array(b), !1] - } - ElementsAreEqual(e, b) { - return this._originalElementsOrHash[e] !== this._modifiedElementsOrHash[b] - ? !1 - : this._hasStrings - ? this._originalStringElements[e] === this._modifiedStringElements[b] - : !0 - } - ElementsAreStrictEqual(e, b) { - if (!this.ElementsAreEqual(e, b)) return !1 - const f = a._getStrictElement(this._originalSequence, e), - v = a._getStrictElement(this._modifiedSequence, b) - return f === v - } - static _getStrictElement(e, b) { - return typeof e.getStrictElement == 'function' ? e.getStrictElement(b) : null - } - OriginalElementsAreEqual(e, b) { - return this._originalElementsOrHash[e] !== this._originalElementsOrHash[b] - ? !1 - : this._hasStrings - ? this._originalStringElements[e] === this._originalStringElements[b] - : !0 - } - ModifiedElementsAreEqual(e, b) { - return this._modifiedElementsOrHash[e] !== this._modifiedElementsOrHash[b] - ? !1 - : this._hasStrings - ? this._modifiedStringElements[e] === this._modifiedStringElements[b] - : !0 - } - ComputeDiff(e) { - return this._ComputeDiff( - 0, - this._originalElementsOrHash.length - 1, - 0, - this._modifiedElementsOrHash.length - 1, - e - ) - } - _ComputeDiff(e, b, f, v, g) { - const S = [!1] - let E = this.ComputeDiffRecursive(e, b, f, v, S) - return g && (E = this.PrettifyChanges(E)), { quitEarly: S[0], changes: E } - } - ComputeDiffRecursive(e, b, f, v, g) { - for (g[0] = !1; e <= b && f <= v && this.ElementsAreEqual(e, f); ) e++, f++ - for (; b >= e && v >= f && this.ElementsAreEqual(b, v); ) b--, v-- - if (e > b || f > v) { - let C - return ( - f <= v - ? (p.Assert(e === b + 1, 'originalStart should only be one more than originalEnd'), - (C = [new R.DiffChange(e, 0, f, v - f + 1)])) - : e <= b - ? (p.Assert(f === v + 1, 'modifiedStart should only be one more than modifiedEnd'), - (C = [new R.DiffChange(e, b - e + 1, f, 0)])) - : (p.Assert(e === b + 1, 'originalStart should only be one more than originalEnd'), - p.Assert(f === v + 1, 'modifiedStart should only be one more than modifiedEnd'), - (C = [])), - C - ) - } - const S = [0], - E = [0], - y = this.ComputeRecursionPoint(e, b, f, v, S, E, g), - _ = S[0], - d = E[0] - if (y !== null) return y - if (!g[0]) { - const C = this.ComputeDiffRecursive(e, _, f, d, g) - let r = [] - return ( - g[0] - ? (r = [new R.DiffChange(_ + 1, b - (_ + 1) + 1, d + 1, v - (d + 1) + 1)]) - : (r = this.ComputeDiffRecursive(_ + 1, b, d + 1, v, g)), - this.ConcatenateChanges(C, r) - ) - } - return [new R.DiffChange(e, b - e + 1, f, v - f + 1)] - } - WALKTRACE(e, b, f, v, g, S, E, y, _, d, C, r, u, o, c, l, m, N) { - let A = null, - M = null, - k = new h(), - q = b, - I = f, - B = u[0] - l[0] - v, - H = -1073741824, - F = this.m_forwardHistory.length - 1 - do { - const U = B + e - U === q || (U < I && _[U - 1] < _[U + 1]) - ? ((C = _[U + 1]), - (o = C - B - v), - C < H && k.MarkNextChange(), - (H = C), - k.AddModifiedElement(C + 1, o), - (B = U + 1 - e)) - : ((C = _[U - 1] + 1), - (o = C - B - v), - C < H && k.MarkNextChange(), - (H = C - 1), - k.AddOriginalElement(C, o + 1), - (B = U - 1 - e)), - F >= 0 && ((_ = this.m_forwardHistory[F]), (e = _[0]), (q = 1), (I = _.length - 1)) - } while (--F >= -1) - if (((A = k.getReverseChanges()), N[0])) { - let U = u[0] + 1, - T = l[0] + 1 - if (A !== null && A.length > 0) { - const W = A[A.length - 1] - ;(U = Math.max(U, W.getOriginalEnd())), (T = Math.max(T, W.getModifiedEnd())) - } - M = [new R.DiffChange(U, r - U + 1, T, c - T + 1)] - } else { - ;(k = new h()), - (q = S), - (I = E), - (B = u[0] - l[0] - y), - (H = 1073741824), - (F = m ? this.m_reverseHistory.length - 1 : this.m_reverseHistory.length - 2) - do { - const U = B + g - U === q || (U < I && d[U - 1] >= d[U + 1]) - ? ((C = d[U + 1] - 1), - (o = C - B - y), - C > H && k.MarkNextChange(), - (H = C + 1), - k.AddOriginalElement(C + 1, o + 1), - (B = U + 1 - g)) - : ((C = d[U - 1]), - (o = C - B - y), - C > H && k.MarkNextChange(), - (H = C), - k.AddModifiedElement(C + 1, o + 1), - (B = U - 1 - g)), - F >= 0 && ((d = this.m_reverseHistory[F]), (g = d[0]), (q = 1), (I = d.length - 1)) - } while (--F >= -1) - M = k.getChanges() - } - return this.ConcatenateChanges(A, M) - } - ComputeRecursionPoint(e, b, f, v, g, S, E) { - let y = 0, - _ = 0, - d = 0, - C = 0, - r = 0, - u = 0 - e--, - f--, - (g[0] = 0), - (S[0] = 0), - (this.m_forwardHistory = []), - (this.m_reverseHistory = []) - const o = b - e + (v - f), - c = o + 1, - l = new Int32Array(c), - m = new Int32Array(c), - N = v - f, - A = b - e, - M = e - f, - k = b - v, - I = (A - N) % 2 === 0 - ;(l[N] = e), (m[A] = b), (E[0] = !1) - for (let B = 1; B <= o / 2 + 1; B++) { - let H = 0, - F = 0 - ;(d = this.ClipDiagonalBound(N - B, B, N, c)), - (C = this.ClipDiagonalBound(N + B, B, N, c)) - for (let T = d; T <= C; T += 2) { - T === d || (T < C && l[T - 1] < l[T + 1]) ? (y = l[T + 1]) : (y = l[T - 1] + 1), - (_ = y - (T - N) - M) - const W = y - for (; y < b && _ < v && this.ElementsAreEqual(y + 1, _ + 1); ) y++, _++ - if ( - ((l[T] = y), - y + _ > H + F && ((H = y), (F = _)), - !I && Math.abs(T - A) <= B - 1 && y >= m[T]) - ) - return ( - (g[0] = y), - (S[0] = _), - W <= m[T] && 1447 > 0 && B <= 1447 + 1 - ? this.WALKTRACE(N, d, C, M, A, r, u, k, l, m, y, b, g, _, v, S, I, E) - : null - ) - } - const U = (H - e + (F - f) - B) / 2 - if ( - this.ContinueProcessingPredicate !== null && - !this.ContinueProcessingPredicate(H, U) - ) - return ( - (E[0] = !0), - (g[0] = H), - (S[0] = F), - U > 0 && 1447 > 0 && B <= 1447 + 1 - ? this.WALKTRACE(N, d, C, M, A, r, u, k, l, m, y, b, g, _, v, S, I, E) - : (e++, f++, [new R.DiffChange(e, b - e + 1, f, v - f + 1)]) - ) - ;(r = this.ClipDiagonalBound(A - B, B, A, c)), - (u = this.ClipDiagonalBound(A + B, B, A, c)) - for (let T = r; T <= u; T += 2) { - T === r || (T < u && m[T - 1] >= m[T + 1]) ? (y = m[T + 1] - 1) : (y = m[T - 1]), - (_ = y - (T - A) - k) - const W = y - for (; y > e && _ > f && this.ElementsAreEqual(y, _); ) y--, _-- - if (((m[T] = y), I && Math.abs(T - N) <= B && y <= l[T])) - return ( - (g[0] = y), - (S[0] = _), - W >= l[T] && 1447 > 0 && B <= 1447 + 1 - ? this.WALKTRACE(N, d, C, M, A, r, u, k, l, m, y, b, g, _, v, S, I, E) - : null - ) - } - if (B <= 1447) { - let T = new Int32Array(C - d + 2) - ;(T[0] = N - d + 1), - L.Copy2(l, d, T, 1, C - d + 1), - this.m_forwardHistory.push(T), - (T = new Int32Array(u - r + 2)), - (T[0] = A - r + 1), - L.Copy2(m, r, T, 1, u - r + 1), - this.m_reverseHistory.push(T) - } - } - return this.WALKTRACE(N, d, C, M, A, r, u, k, l, m, y, b, g, _, v, S, I, E) - } - PrettifyChanges(e) { - for (let b = 0; b < e.length; b++) { - const f = e[b], - v = b < e.length - 1 ? e[b + 1].originalStart : this._originalElementsOrHash.length, - g = b < e.length - 1 ? e[b + 1].modifiedStart : this._modifiedElementsOrHash.length, - S = f.originalLength > 0, - E = f.modifiedLength > 0 - for ( - ; - f.originalStart + f.originalLength < v && - f.modifiedStart + f.modifiedLength < g && - (!S || - this.OriginalElementsAreEqual( - f.originalStart, - f.originalStart + f.originalLength - )) && - (!E || - this.ModifiedElementsAreEqual(f.modifiedStart, f.modifiedStart + f.modifiedLength)); - - ) { - const _ = this.ElementsAreStrictEqual(f.originalStart, f.modifiedStart) - if ( - this.ElementsAreStrictEqual( - f.originalStart + f.originalLength, - f.modifiedStart + f.modifiedLength - ) && - !_ - ) - break - f.originalStart++, f.modifiedStart++ - } - const y = [null] - if (b < e.length - 1 && this.ChangesOverlap(e[b], e[b + 1], y)) { - ;(e[b] = y[0]), e.splice(b + 1, 1), b-- - continue - } - } - for (let b = e.length - 1; b >= 0; b--) { - const f = e[b] - let v = 0, - g = 0 - if (b > 0) { - const C = e[b - 1] - ;(v = C.originalStart + C.originalLength), (g = C.modifiedStart + C.modifiedLength) - } - const S = f.originalLength > 0, - E = f.modifiedLength > 0 - let y = 0, - _ = this._boundaryScore( - f.originalStart, - f.originalLength, - f.modifiedStart, - f.modifiedLength - ) - for (let C = 1; ; C++) { - const r = f.originalStart - C, - u = f.modifiedStart - C - if ( - r < v || - u < g || - (S && !this.OriginalElementsAreEqual(r, r + f.originalLength)) || - (E && !this.ModifiedElementsAreEqual(u, u + f.modifiedLength)) - ) - break - const c = - (r === v && u === g ? 5 : 0) + - this._boundaryScore(r, f.originalLength, u, f.modifiedLength) - c > _ && ((_ = c), (y = C)) - } - ;(f.originalStart -= y), (f.modifiedStart -= y) - const d = [null] - if (b > 0 && this.ChangesOverlap(e[b - 1], e[b], d)) { - ;(e[b - 1] = d[0]), e.splice(b, 1), b++ - continue - } - } - if (this._hasStrings) - for (let b = 1, f = e.length; b < f; b++) { - const v = e[b - 1], - g = e[b], - S = g.originalStart - v.originalStart - v.originalLength, - E = v.originalStart, - y = g.originalStart + g.originalLength, - _ = y - E, - d = v.modifiedStart, - C = g.modifiedStart + g.modifiedLength, - r = C - d - if (S < 5 && _ < 20 && r < 20) { - const u = this._findBetterContiguousSequence(E, _, d, r, S) - if (u) { - const [o, c] = u - ;(o !== v.originalStart + v.originalLength || - c !== v.modifiedStart + v.modifiedLength) && - ((v.originalLength = o - v.originalStart), - (v.modifiedLength = c - v.modifiedStart), - (g.originalStart = o + S), - (g.modifiedStart = c + S), - (g.originalLength = y - g.originalStart), - (g.modifiedLength = C - g.modifiedStart)) - } - } - } - return e - } - _findBetterContiguousSequence(e, b, f, v, g) { - if (b < g || v < g) return null - const S = e + b - g + 1, - E = f + v - g + 1 - let y = 0, - _ = 0, - d = 0 - for (let C = e; C < S; C++) - for (let r = f; r < E; r++) { - const u = this._contiguousSequenceScore(C, r, g) - u > 0 && u > y && ((y = u), (_ = C), (d = r)) - } - return y > 0 ? [_, d] : null - } - _contiguousSequenceScore(e, b, f) { - let v = 0 - for (let g = 0; g < f; g++) { - if (!this.ElementsAreEqual(e + g, b + g)) return 0 - v += this._originalStringElements[e + g].length - } - return v - } - _OriginalIsBoundary(e) { - return e <= 0 || e >= this._originalElementsOrHash.length - 1 - ? !0 - : this._hasStrings && /^\s*$/.test(this._originalStringElements[e]) - } - _OriginalRegionIsBoundary(e, b) { - if (this._OriginalIsBoundary(e) || this._OriginalIsBoundary(e - 1)) return !0 - if (b > 0) { - const f = e + b - if (this._OriginalIsBoundary(f - 1) || this._OriginalIsBoundary(f)) return !0 - } - return !1 - } - _ModifiedIsBoundary(e) { - return e <= 0 || e >= this._modifiedElementsOrHash.length - 1 - ? !0 - : this._hasStrings && /^\s*$/.test(this._modifiedStringElements[e]) - } - _ModifiedRegionIsBoundary(e, b) { - if (this._ModifiedIsBoundary(e) || this._ModifiedIsBoundary(e - 1)) return !0 - if (b > 0) { - const f = e + b - if (this._ModifiedIsBoundary(f - 1) || this._ModifiedIsBoundary(f)) return !0 - } - return !1 - } - _boundaryScore(e, b, f, v) { - const g = this._OriginalRegionIsBoundary(e, b) ? 1 : 0, - S = this._ModifiedRegionIsBoundary(f, v) ? 1 : 0 - return g + S - } - ConcatenateChanges(e, b) { - const f = [] - if (e.length === 0 || b.length === 0) return b.length > 0 ? b : e - if (this.ChangesOverlap(e[e.length - 1], b[0], f)) { - const v = new Array(e.length + b.length - 1) - return ( - L.Copy(e, 0, v, 0, e.length - 1), - (v[e.length - 1] = f[0]), - L.Copy(b, 1, v, e.length, b.length - 1), - v - ) - } else { - const v = new Array(e.length + b.length) - return L.Copy(e, 0, v, 0, e.length), L.Copy(b, 0, v, e.length, b.length), v - } - } - ChangesOverlap(e, b, f) { - if ( - (p.Assert( - e.originalStart <= b.originalStart, - 'Left change is not less than or equal to right change' - ), - p.Assert( - e.modifiedStart <= b.modifiedStart, - 'Left change is not less than or equal to right change' - ), - e.originalStart + e.originalLength >= b.originalStart || - e.modifiedStart + e.modifiedLength >= b.modifiedStart) - ) { - const v = e.originalStart - let g = e.originalLength - const S = e.modifiedStart - let E = e.modifiedLength - return ( - e.originalStart + e.originalLength >= b.originalStart && - (g = b.originalStart + b.originalLength - e.originalStart), - e.modifiedStart + e.modifiedLength >= b.modifiedStart && - (E = b.modifiedStart + b.modifiedLength - e.modifiedStart), - (f[0] = new R.DiffChange(v, g, S, E)), - !0 - ) - } else return (f[0] = null), !1 - } - ClipDiagonalBound(e, b, f, v) { - if (e >= 0 && e < v) return e - const g = f, - S = v - f - 1, - E = b % 2 === 0 - if (e < 0) { - const y = g % 2 === 0 - return E === y ? 0 : 1 - } else { - const y = S % 2 === 0 - return E === y ? v - 1 : v - 2 - } - } - } - n.LcsDiff = a - }), - Q(Y[18], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.withNullAsUndefined = - n.validateConstraint = - n.validateConstraints = - n.isFunction = - n.assertIsDefined = - n.assertType = - n.isUndefinedOrNull = - n.isDefined = - n.isUndefined = - n.isBoolean = - n.isIterable = - n.isNumber = - n.isTypedArray = - n.isObject = - n.isString = - void 0) - function R(E) { - return typeof E == 'string' - } - n.isString = R - function D(E) { - return ( - typeof E == 'object' && - E !== null && - !Array.isArray(E) && - !(E instanceof RegExp) && - !(E instanceof Date) - ) - } - n.isObject = D - function i(E) { - const y = Object.getPrototypeOf(Uint8Array) - return typeof E == 'object' && E instanceof y - } - n.isTypedArray = i - function s(E) { - return typeof E == 'number' && !isNaN(E) - } - n.isNumber = s - function p(E) { - return !!E && typeof E[Symbol.iterator] == 'function' - } - n.isIterable = p - function L(E) { - return E === !0 || E === !1 - } - n.isBoolean = L - function h(E) { - return typeof E > 'u' - } - n.isUndefined = h - function a(E) { - return !w(E) - } - n.isDefined = a - function w(E) { - return h(E) || E === null - } - n.isUndefinedOrNull = w - function e(E, y) { - if (!E) throw new Error(y ? `Unexpected type, expected '${y}'` : 'Unexpected type') - } - n.assertType = e - function b(E) { - if (w(E)) throw new Error('Assertion Failed: argument is undefined or null') - return E - } - n.assertIsDefined = b - function f(E) { - return typeof E == 'function' - } - n.isFunction = f - function v(E, y) { - const _ = Math.min(E.length, y.length) - for (let d = 0; d < _; d++) g(E[d], y[d]) - } - n.validateConstraints = v - function g(E, y) { - if (R(y)) { - if (typeof E !== y) throw new Error(`argument does not match constraint: typeof ${y}`) - } else if (f(y)) { - try { - if (E instanceof y) return - } catch {} - if ((!w(E) && E.constructor === y) || (y.length === 1 && y.call(void 0, E) === !0)) return - throw new Error( - 'argument does not match one of these constraints: arg instanceof constraint, arg.constructor === constraint, nor constraint(arg) === true' - ) - } - } - n.validateConstraint = g - function S(E) { - return E === null ? void 0 : E - } - n.withNullAsUndefined = S - }), - Q(Y[32], X([0, 1, 18]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.Codicon = n.getCodiconFontCharacters = void 0) - const D = Object.create(null) - function i(p, L) { - if ((0, R.isString)(L)) { - const h = D[L] - if (h === void 0) throw new Error(`${p} references an unknown codicon: ${L}`) - L = h - } - return (D[p] = L), { id: p } - } - function s() { - return D - } - ;(n.getCodiconFontCharacters = s), - (n.Codicon = { - add: i('add', 6e4), - plus: i('plus', 6e4), - gistNew: i('gist-new', 6e4), - repoCreate: i('repo-create', 6e4), - lightbulb: i('lightbulb', 60001), - lightBulb: i('light-bulb', 60001), - repo: i('repo', 60002), - repoDelete: i('repo-delete', 60002), - gistFork: i('gist-fork', 60003), - repoForked: i('repo-forked', 60003), - gitPullRequest: i('git-pull-request', 60004), - gitPullRequestAbandoned: i('git-pull-request-abandoned', 60004), - recordKeys: i('record-keys', 60005), - keyboard: i('keyboard', 60005), - tag: i('tag', 60006), - tagAdd: i('tag-add', 60006), - tagRemove: i('tag-remove', 60006), - person: i('person', 60007), - personFollow: i('person-follow', 60007), - personOutline: i('person-outline', 60007), - personFilled: i('person-filled', 60007), - gitBranch: i('git-branch', 60008), - gitBranchCreate: i('git-branch-create', 60008), - gitBranchDelete: i('git-branch-delete', 60008), - sourceControl: i('source-control', 60008), - mirror: i('mirror', 60009), - mirrorPublic: i('mirror-public', 60009), - star: i('star', 60010), - starAdd: i('star-add', 60010), - starDelete: i('star-delete', 60010), - starEmpty: i('star-empty', 60010), - comment: i('comment', 60011), - commentAdd: i('comment-add', 60011), - alert: i('alert', 60012), - warning: i('warning', 60012), - search: i('search', 60013), - searchSave: i('search-save', 60013), - logOut: i('log-out', 60014), - signOut: i('sign-out', 60014), - logIn: i('log-in', 60015), - signIn: i('sign-in', 60015), - eye: i('eye', 60016), - eyeUnwatch: i('eye-unwatch', 60016), - eyeWatch: i('eye-watch', 60016), - circleFilled: i('circle-filled', 60017), - primitiveDot: i('primitive-dot', 60017), - closeDirty: i('close-dirty', 60017), - debugBreakpoint: i('debug-breakpoint', 60017), - debugBreakpointDisabled: i('debug-breakpoint-disabled', 60017), - debugHint: i('debug-hint', 60017), - primitiveSquare: i('primitive-square', 60018), - edit: i('edit', 60019), - pencil: i('pencil', 60019), - info: i('info', 60020), - issueOpened: i('issue-opened', 60020), - gistPrivate: i('gist-private', 60021), - gitForkPrivate: i('git-fork-private', 60021), - lock: i('lock', 60021), - mirrorPrivate: i('mirror-private', 60021), - close: i('close', 60022), - removeClose: i('remove-close', 60022), - x: i('x', 60022), - repoSync: i('repo-sync', 60023), - sync: i('sync', 60023), - clone: i('clone', 60024), - desktopDownload: i('desktop-download', 60024), - beaker: i('beaker', 60025), - microscope: i('microscope', 60025), - vm: i('vm', 60026), - deviceDesktop: i('device-desktop', 60026), - file: i('file', 60027), - fileText: i('file-text', 60027), - more: i('more', 60028), - ellipsis: i('ellipsis', 60028), - kebabHorizontal: i('kebab-horizontal', 60028), - mailReply: i('mail-reply', 60029), - reply: i('reply', 60029), - organization: i('organization', 60030), - organizationFilled: i('organization-filled', 60030), - organizationOutline: i('organization-outline', 60030), - newFile: i('new-file', 60031), - fileAdd: i('file-add', 60031), - newFolder: i('new-folder', 60032), - fileDirectoryCreate: i('file-directory-create', 60032), - trash: i('trash', 60033), - trashcan: i('trashcan', 60033), - history: i('history', 60034), - clock: i('clock', 60034), - folder: i('folder', 60035), - fileDirectory: i('file-directory', 60035), - symbolFolder: i('symbol-folder', 60035), - logoGithub: i('logo-github', 60036), - markGithub: i('mark-github', 60036), - github: i('github', 60036), - terminal: i('terminal', 60037), - console: i('console', 60037), - repl: i('repl', 60037), - zap: i('zap', 60038), - symbolEvent: i('symbol-event', 60038), - error: i('error', 60039), - stop: i('stop', 60039), - variable: i('variable', 60040), - symbolVariable: i('symbol-variable', 60040), - array: i('array', 60042), - symbolArray: i('symbol-array', 60042), - symbolModule: i('symbol-module', 60043), - symbolPackage: i('symbol-package', 60043), - symbolNamespace: i('symbol-namespace', 60043), - symbolObject: i('symbol-object', 60043), - symbolMethod: i('symbol-method', 60044), - symbolFunction: i('symbol-function', 60044), - symbolConstructor: i('symbol-constructor', 60044), - symbolBoolean: i('symbol-boolean', 60047), - symbolNull: i('symbol-null', 60047), - symbolNumeric: i('symbol-numeric', 60048), - symbolNumber: i('symbol-number', 60048), - symbolStructure: i('symbol-structure', 60049), - symbolStruct: i('symbol-struct', 60049), - symbolParameter: i('symbol-parameter', 60050), - symbolTypeParameter: i('symbol-type-parameter', 60050), - symbolKey: i('symbol-key', 60051), - symbolText: i('symbol-text', 60051), - symbolReference: i('symbol-reference', 60052), - goToFile: i('go-to-file', 60052), - symbolEnum: i('symbol-enum', 60053), - symbolValue: i('symbol-value', 60053), - symbolRuler: i('symbol-ruler', 60054), - symbolUnit: i('symbol-unit', 60054), - activateBreakpoints: i('activate-breakpoints', 60055), - archive: i('archive', 60056), - arrowBoth: i('arrow-both', 60057), - arrowDown: i('arrow-down', 60058), - arrowLeft: i('arrow-left', 60059), - arrowRight: i('arrow-right', 60060), - arrowSmallDown: i('arrow-small-down', 60061), - arrowSmallLeft: i('arrow-small-left', 60062), - arrowSmallRight: i('arrow-small-right', 60063), - arrowSmallUp: i('arrow-small-up', 60064), - arrowUp: i('arrow-up', 60065), - bell: i('bell', 60066), - bold: i('bold', 60067), - book: i('book', 60068), - bookmark: i('bookmark', 60069), - debugBreakpointConditionalUnverified: i('debug-breakpoint-conditional-unverified', 60070), - debugBreakpointConditional: i('debug-breakpoint-conditional', 60071), - debugBreakpointConditionalDisabled: i('debug-breakpoint-conditional-disabled', 60071), - debugBreakpointDataUnverified: i('debug-breakpoint-data-unverified', 60072), - debugBreakpointData: i('debug-breakpoint-data', 60073), - debugBreakpointDataDisabled: i('debug-breakpoint-data-disabled', 60073), - debugBreakpointLogUnverified: i('debug-breakpoint-log-unverified', 60074), - debugBreakpointLog: i('debug-breakpoint-log', 60075), - debugBreakpointLogDisabled: i('debug-breakpoint-log-disabled', 60075), - briefcase: i('briefcase', 60076), - broadcast: i('broadcast', 60077), - browser: i('browser', 60078), - bug: i('bug', 60079), - calendar: i('calendar', 60080), - caseSensitive: i('case-sensitive', 60081), - check: i('check', 60082), - checklist: i('checklist', 60083), - chevronDown: i('chevron-down', 60084), - dropDownButton: i('drop-down-button', 60084), - chevronLeft: i('chevron-left', 60085), - chevronRight: i('chevron-right', 60086), - chevronUp: i('chevron-up', 60087), - chromeClose: i('chrome-close', 60088), - chromeMaximize: i('chrome-maximize', 60089), - chromeMinimize: i('chrome-minimize', 60090), - chromeRestore: i('chrome-restore', 60091), - circle: i('circle', 60092), - circleOutline: i('circle-outline', 60092), - debugBreakpointUnverified: i('debug-breakpoint-unverified', 60092), - circleSlash: i('circle-slash', 60093), - circuitBoard: i('circuit-board', 60094), - clearAll: i('clear-all', 60095), - clippy: i('clippy', 60096), - closeAll: i('close-all', 60097), - cloudDownload: i('cloud-download', 60098), - cloudUpload: i('cloud-upload', 60099), - code: i('code', 60100), - collapseAll: i('collapse-all', 60101), - colorMode: i('color-mode', 60102), - commentDiscussion: i('comment-discussion', 60103), - compareChanges: i('compare-changes', 60157), - creditCard: i('credit-card', 60105), - dash: i('dash', 60108), - dashboard: i('dashboard', 60109), - database: i('database', 60110), - debugContinue: i('debug-continue', 60111), - debugDisconnect: i('debug-disconnect', 60112), - debugPause: i('debug-pause', 60113), - debugRestart: i('debug-restart', 60114), - debugStart: i('debug-start', 60115), - debugStepInto: i('debug-step-into', 60116), - debugStepOut: i('debug-step-out', 60117), - debugStepOver: i('debug-step-over', 60118), - debugStop: i('debug-stop', 60119), - debug: i('debug', 60120), - deviceCameraVideo: i('device-camera-video', 60121), - deviceCamera: i('device-camera', 60122), - deviceMobile: i('device-mobile', 60123), - diffAdded: i('diff-added', 60124), - diffIgnored: i('diff-ignored', 60125), - diffModified: i('diff-modified', 60126), - diffRemoved: i('diff-removed', 60127), - diffRenamed: i('diff-renamed', 60128), - diff: i('diff', 60129), - discard: i('discard', 60130), - editorLayout: i('editor-layout', 60131), - emptyWindow: i('empty-window', 60132), - exclude: i('exclude', 60133), - extensions: i('extensions', 60134), - eyeClosed: i('eye-closed', 60135), - fileBinary: i('file-binary', 60136), - fileCode: i('file-code', 60137), - fileMedia: i('file-media', 60138), - filePdf: i('file-pdf', 60139), - fileSubmodule: i('file-submodule', 60140), - fileSymlinkDirectory: i('file-symlink-directory', 60141), - fileSymlinkFile: i('file-symlink-file', 60142), - fileZip: i('file-zip', 60143), - files: i('files', 60144), - filter: i('filter', 60145), - flame: i('flame', 60146), - foldDown: i('fold-down', 60147), - foldUp: i('fold-up', 60148), - fold: i('fold', 60149), - folderActive: i('folder-active', 60150), - folderOpened: i('folder-opened', 60151), - gear: i('gear', 60152), - gift: i('gift', 60153), - gistSecret: i('gist-secret', 60154), - gist: i('gist', 60155), - gitCommit: i('git-commit', 60156), - gitCompare: i('git-compare', 60157), - gitMerge: i('git-merge', 60158), - githubAction: i('github-action', 60159), - githubAlt: i('github-alt', 60160), - globe: i('globe', 60161), - grabber: i('grabber', 60162), - graph: i('graph', 60163), - gripper: i('gripper', 60164), - heart: i('heart', 60165), - home: i('home', 60166), - horizontalRule: i('horizontal-rule', 60167), - hubot: i('hubot', 60168), - inbox: i('inbox', 60169), - issueClosed: i('issue-closed', 60324), - issueReopened: i('issue-reopened', 60171), - issues: i('issues', 60172), - italic: i('italic', 60173), - jersey: i('jersey', 60174), - json: i('json', 60175), - bracket: i('bracket', 60175), - kebabVertical: i('kebab-vertical', 60176), - key: i('key', 60177), - law: i('law', 60178), - lightbulbAutofix: i('lightbulb-autofix', 60179), - linkExternal: i('link-external', 60180), - link: i('link', 60181), - listOrdered: i('list-ordered', 60182), - listUnordered: i('list-unordered', 60183), - liveShare: i('live-share', 60184), - loading: i('loading', 60185), - location: i('location', 60186), - mailRead: i('mail-read', 60187), - mail: i('mail', 60188), - markdown: i('markdown', 60189), - megaphone: i('megaphone', 60190), - mention: i('mention', 60191), - milestone: i('milestone', 60192), - mortarBoard: i('mortar-board', 60193), - move: i('move', 60194), - multipleWindows: i('multiple-windows', 60195), - mute: i('mute', 60196), - noNewline: i('no-newline', 60197), - note: i('note', 60198), - octoface: i('octoface', 60199), - openPreview: i('open-preview', 60200), - package_: i('package', 60201), - paintcan: i('paintcan', 60202), - pin: i('pin', 60203), - play: i('play', 60204), - run: i('run', 60204), - plug: i('plug', 60205), - preserveCase: i('preserve-case', 60206), - preview: i('preview', 60207), - project: i('project', 60208), - pulse: i('pulse', 60209), - question: i('question', 60210), - quote: i('quote', 60211), - radioTower: i('radio-tower', 60212), - reactions: i('reactions', 60213), - references: i('references', 60214), - refresh: i('refresh', 60215), - regex: i('regex', 60216), - remoteExplorer: i('remote-explorer', 60217), - remote: i('remote', 60218), - remove: i('remove', 60219), - replaceAll: i('replace-all', 60220), - replace: i('replace', 60221), - repoClone: i('repo-clone', 60222), - repoForcePush: i('repo-force-push', 60223), - repoPull: i('repo-pull', 60224), - repoPush: i('repo-push', 60225), - report: i('report', 60226), - requestChanges: i('request-changes', 60227), - rocket: i('rocket', 60228), - rootFolderOpened: i('root-folder-opened', 60229), - rootFolder: i('root-folder', 60230), - rss: i('rss', 60231), - ruby: i('ruby', 60232), - saveAll: i('save-all', 60233), - saveAs: i('save-as', 60234), - save: i('save', 60235), - screenFull: i('screen-full', 60236), - screenNormal: i('screen-normal', 60237), - searchStop: i('search-stop', 60238), - server: i('server', 60240), - settingsGear: i('settings-gear', 60241), - settings: i('settings', 60242), - shield: i('shield', 60243), - smiley: i('smiley', 60244), - sortPrecedence: i('sort-precedence', 60245), - splitHorizontal: i('split-horizontal', 60246), - splitVertical: i('split-vertical', 60247), - squirrel: i('squirrel', 60248), - starFull: i('star-full', 60249), - starHalf: i('star-half', 60250), - symbolClass: i('symbol-class', 60251), - symbolColor: i('symbol-color', 60252), - symbolCustomColor: i('symbol-customcolor', 60252), - symbolConstant: i('symbol-constant', 60253), - symbolEnumMember: i('symbol-enum-member', 60254), - symbolField: i('symbol-field', 60255), - symbolFile: i('symbol-file', 60256), - symbolInterface: i('symbol-interface', 60257), - symbolKeyword: i('symbol-keyword', 60258), - symbolMisc: i('symbol-misc', 60259), - symbolOperator: i('symbol-operator', 60260), - symbolProperty: i('symbol-property', 60261), - wrench: i('wrench', 60261), - wrenchSubaction: i('wrench-subaction', 60261), - symbolSnippet: i('symbol-snippet', 60262), - tasklist: i('tasklist', 60263), - telescope: i('telescope', 60264), - textSize: i('text-size', 60265), - threeBars: i('three-bars', 60266), - thumbsdown: i('thumbsdown', 60267), - thumbsup: i('thumbsup', 60268), - tools: i('tools', 60269), - triangleDown: i('triangle-down', 60270), - triangleLeft: i('triangle-left', 60271), - triangleRight: i('triangle-right', 60272), - triangleUp: i('triangle-up', 60273), - twitter: i('twitter', 60274), - unfold: i('unfold', 60275), - unlock: i('unlock', 60276), - unmute: i('unmute', 60277), - unverified: i('unverified', 60278), - verified: i('verified', 60279), - versions: i('versions', 60280), - vmActive: i('vm-active', 60281), - vmOutline: i('vm-outline', 60282), - vmRunning: i('vm-running', 60283), - watch: i('watch', 60284), - whitespace: i('whitespace', 60285), - wholeWord: i('whole-word', 60286), - window: i('window', 60287), - wordWrap: i('word-wrap', 60288), - zoomIn: i('zoom-in', 60289), - zoomOut: i('zoom-out', 60290), - listFilter: i('list-filter', 60291), - listFlat: i('list-flat', 60292), - listSelection: i('list-selection', 60293), - selection: i('selection', 60293), - listTree: i('list-tree', 60294), - debugBreakpointFunctionUnverified: i('debug-breakpoint-function-unverified', 60295), - debugBreakpointFunction: i('debug-breakpoint-function', 60296), - debugBreakpointFunctionDisabled: i('debug-breakpoint-function-disabled', 60296), - debugStackframeActive: i('debug-stackframe-active', 60297), - circleSmallFilled: i('circle-small-filled', 60298), - debugStackframeDot: i('debug-stackframe-dot', 60298), - debugStackframe: i('debug-stackframe', 60299), - debugStackframeFocused: i('debug-stackframe-focused', 60299), - debugBreakpointUnsupported: i('debug-breakpoint-unsupported', 60300), - symbolString: i('symbol-string', 60301), - debugReverseContinue: i('debug-reverse-continue', 60302), - debugStepBack: i('debug-step-back', 60303), - debugRestartFrame: i('debug-restart-frame', 60304), - callIncoming: i('call-incoming', 60306), - callOutgoing: i('call-outgoing', 60307), - menu: i('menu', 60308), - expandAll: i('expand-all', 60309), - feedback: i('feedback', 60310), - groupByRefType: i('group-by-ref-type', 60311), - ungroupByRefType: i('ungroup-by-ref-type', 60312), - account: i('account', 60313), - bellDot: i('bell-dot', 60314), - debugConsole: i('debug-console', 60315), - library: i('library', 60316), - output: i('output', 60317), - runAll: i('run-all', 60318), - syncIgnored: i('sync-ignored', 60319), - pinned: i('pinned', 60320), - githubInverted: i('github-inverted', 60321), - debugAlt: i('debug-alt', 60305), - serverProcess: i('server-process', 60322), - serverEnvironment: i('server-environment', 60323), - pass: i('pass', 60324), - stopCircle: i('stop-circle', 60325), - playCircle: i('play-circle', 60326), - record: i('record', 60327), - debugAltSmall: i('debug-alt-small', 60328), - vmConnect: i('vm-connect', 60329), - cloud: i('cloud', 60330), - merge: i('merge', 60331), - exportIcon: i('export', 60332), - graphLeft: i('graph-left', 60333), - magnet: i('magnet', 60334), - notebook: i('notebook', 60335), - redo: i('redo', 60336), - checkAll: i('check-all', 60337), - pinnedDirty: i('pinned-dirty', 60338), - passFilled: i('pass-filled', 60339), - circleLargeFilled: i('circle-large-filled', 60340), - circleLarge: i('circle-large', 60341), - circleLargeOutline: i('circle-large-outline', 60341), - combine: i('combine', 60342), - gather: i('gather', 60342), - table: i('table', 60343), - variableGroup: i('variable-group', 60344), - typeHierarchy: i('type-hierarchy', 60345), - typeHierarchySub: i('type-hierarchy-sub', 60346), - typeHierarchySuper: i('type-hierarchy-super', 60347), - gitPullRequestCreate: i('git-pull-request-create', 60348), - runAbove: i('run-above', 60349), - runBelow: i('run-below', 60350), - notebookTemplate: i('notebook-template', 60351), - debugRerun: i('debug-rerun', 60352), - workspaceTrusted: i('workspace-trusted', 60353), - workspaceUntrusted: i('workspace-untrusted', 60354), - workspaceUnspecified: i('workspace-unspecified', 60355), - terminalCmd: i('terminal-cmd', 60356), - terminalDebian: i('terminal-debian', 60357), - terminalLinux: i('terminal-linux', 60358), - terminalPowershell: i('terminal-powershell', 60359), - terminalTmux: i('terminal-tmux', 60360), - terminalUbuntu: i('terminal-ubuntu', 60361), - terminalBash: i('terminal-bash', 60362), - arrowSwap: i('arrow-swap', 60363), - copy: i('copy', 60364), - personAdd: i('person-add', 60365), - filterFilled: i('filter-filled', 60366), - wand: i('wand', 60367), - debugLineByLine: i('debug-line-by-line', 60368), - inspect: i('inspect', 60369), - layers: i('layers', 60370), - layersDot: i('layers-dot', 60371), - layersActive: i('layers-active', 60372), - compass: i('compass', 60373), - compassDot: i('compass-dot', 60374), - compassActive: i('compass-active', 60375), - azure: i('azure', 60376), - issueDraft: i('issue-draft', 60377), - gitPullRequestClosed: i('git-pull-request-closed', 60378), - gitPullRequestDraft: i('git-pull-request-draft', 60379), - debugAll: i('debug-all', 60380), - debugCoverage: i('debug-coverage', 60381), - runErrors: i('run-errors', 60382), - folderLibrary: i('folder-library', 60383), - debugContinueSmall: i('debug-continue-small', 60384), - beakerStop: i('beaker-stop', 60385), - graphLine: i('graph-line', 60386), - graphScatter: i('graph-scatter', 60387), - pieChart: i('pie-chart', 60388), - bracketDot: i('bracket-dot', 60389), - bracketError: i('bracket-error', 60390), - lockSmall: i('lock-small', 60391), - azureDevops: i('azure-devops', 60392), - verifiedFilled: i('verified-filled', 60393), - newLine: i('newline', 60394), - layout: i('layout', 60395), - layoutActivitybarLeft: i('layout-activitybar-left', 60396), - layoutActivitybarRight: i('layout-activitybar-right', 60397), - layoutPanelLeft: i('layout-panel-left', 60398), - layoutPanelCenter: i('layout-panel-center', 60399), - layoutPanelJustify: i('layout-panel-justify', 60400), - layoutPanelRight: i('layout-panel-right', 60401), - layoutPanel: i('layout-panel', 60402), - layoutSidebarLeft: i('layout-sidebar-left', 60403), - layoutSidebarRight: i('layout-sidebar-right', 60404), - layoutStatusbar: i('layout-statusbar', 60405), - layoutMenubar: i('layout-menubar', 60406), - layoutCentered: i('layout-centered', 60407), - layoutSidebarRightOff: i('layout-sidebar-right-off', 60416), - layoutPanelOff: i('layout-panel-off', 60417), - layoutSidebarLeftOff: i('layout-sidebar-left-off', 60418), - target: i('target', 60408), - indent: i('indent', 60409), - recordSmall: i('record-small', 60410), - errorSmall: i('error-small', 60411), - arrowCircleDown: i('arrow-circle-down', 60412), - arrowCircleLeft: i('arrow-circle-left', 60413), - arrowCircleRight: i('arrow-circle-right', 60414), - arrowCircleUp: i('arrow-circle-up', 60415), - heartFilled: i('heart-filled', 60420), - map: i('map', 60421), - mapFilled: i('map-filled', 60422), - circleSmall: i('circle-small', 60423), - bellSlash: i('bell-slash', 60424), - bellSlashDot: i('bell-slash-dot', 60425), - commentUnresolved: i('comment-unresolved', 60426), - gitPullRequestGoToChanges: i('git-pull-request-go-to-changes', 60427), - gitPullRequestNewChanges: i('git-pull-request-new-changes', 60428), - searchFuzzy: i('search-fuzzy', 60429), - commentDraft: i('comment-draft', 60430), - send: i('send', 60431), - sparkle: i('sparkle', 60432), - insert: i('insert', 60433), - dialogError: i('dialog-error', 'error'), - dialogWarning: i('dialog-warning', 'warning'), - dialogInfo: i('dialog-info', 'info'), - dialogClose: i('dialog-close', 'close'), - treeItemExpanded: i('tree-item-expanded', 'chevron-down'), - treeFilterOnTypeOn: i('tree-filter-on-type-on', 'list-filter'), - treeFilterOnTypeOff: i('tree-filter-on-type-off', 'list-selection'), - treeFilterClear: i('tree-filter-clear', 'close'), - treeItemLoading: i('tree-item-loading', 'loading'), - menuSelection: i('menu-selection', 'check'), - menuSubmenu: i('menu-submenu', 'chevron-right'), - menuBarMore: i('menubar-more', 'more'), - scrollbarButtonLeft: i('scrollbar-button-left', 'triangle-left'), - scrollbarButtonRight: i('scrollbar-button-right', 'triangle-right'), - scrollbarButtonUp: i('scrollbar-button-up', 'triangle-up'), - scrollbarButtonDown: i('scrollbar-button-down', 'triangle-down'), - toolBarMore: i('toolbar-more', 'more'), - quickInputBack: i('quick-input-back', 'arrow-left'), - }) - }), - Q(Y[12], X([0, 1, 18]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.createProxyObject = - n.getAllMethodNames = - n.getAllPropertyNames = - n.equals = - n.mixin = - n.cloneAndChange = - n.deepFreeze = - n.deepClone = - void 0) - function D(f) { - if (!f || typeof f != 'object' || f instanceof RegExp) return f - const v = Array.isArray(f) ? [] : {} - return ( - Object.entries(f).forEach(([g, S]) => { - v[g] = S && typeof S == 'object' ? D(S) : S - }), - v - ) - } - n.deepClone = D - function i(f) { - if (!f || typeof f != 'object') return f - const v = [f] - for (; v.length > 0; ) { - const g = v.shift() - Object.freeze(g) - for (const S in g) - if (s.call(g, S)) { - const E = g[S] - typeof E == 'object' && !Object.isFrozen(E) && !(0, R.isTypedArray)(E) && v.push(E) - } - } - return f - } - n.deepFreeze = i - const s = Object.prototype.hasOwnProperty - function p(f, v) { - return L(f, v, new Set()) - } - n.cloneAndChange = p - function L(f, v, g) { - if ((0, R.isUndefinedOrNull)(f)) return f - const S = v(f) - if (typeof S < 'u') return S - if (Array.isArray(f)) { - const E = [] - for (const y of f) E.push(L(y, v, g)) - return E - } - if ((0, R.isObject)(f)) { - if (g.has(f)) throw new Error('Cannot clone recursive data-structure') - g.add(f) - const E = {} - for (const y in f) s.call(f, y) && (E[y] = L(f[y], v, g)) - return g.delete(f), E - } - return f - } - function h(f, v, g = !0) { - return (0, R.isObject)(f) - ? ((0, R.isObject)(v) && - Object.keys(v).forEach((S) => { - S in f - ? g && - ((0, R.isObject)(f[S]) && (0, R.isObject)(v[S]) - ? h(f[S], v[S], g) - : (f[S] = v[S])) - : (f[S] = v[S]) - }), - f) - : v - } - n.mixin = h - function a(f, v) { - if (f === v) return !0 - if ( - f == null || - v === null || - v === void 0 || - typeof f != typeof v || - typeof f != 'object' || - Array.isArray(f) !== Array.isArray(v) - ) - return !1 - let g, S - if (Array.isArray(f)) { - if (f.length !== v.length) return !1 - for (g = 0; g < f.length; g++) if (!a(f[g], v[g])) return !1 - } else { - const E = [] - for (S in f) E.push(S) - E.sort() - const y = [] - for (S in v) y.push(S) - if ((y.sort(), !a(E, y))) return !1 - for (g = 0; g < E.length; g++) if (!a(f[E[g]], v[E[g]])) return !1 - } - return !0 - } - n.equals = a - function w(f) { - let v = [], - g = Object.getPrototypeOf(f) - for (; Object.prototype !== g; ) - (v = v.concat(Object.getOwnPropertyNames(g))), (g = Object.getPrototypeOf(g)) - return v - } - n.getAllPropertyNames = w - function e(f) { - const v = [] - for (const g of w(f)) typeof f[g] == 'function' && v.push(g) - return v - } - n.getAllMethodNames = e - function b(f, v) { - const g = (E) => - function () { - const y = Array.prototype.slice.call(arguments, 0) - return v(E, y) - }, - S = {} - for (const E of f) S[E] = g(E) - return S - } - n.createProxyObject = b - }), - Q(Y[19], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.toUint32 = n.toUint8 = void 0) - function R(i) { - return i < 0 ? 0 : i > 255 ? 255 : i | 0 - } - n.toUint8 = R - function D(i) { - return i < 0 ? 0 : i > 4294967295 ? 4294967295 : i | 0 - } - n.toUint32 = D - }), - Q(Y[20], X([0, 1, 19]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.CharacterSet = n.CharacterClassifier = void 0) - class D { - constructor(p) { - const L = (0, R.toUint8)(p) - ;(this._defaultValue = L), - (this._asciiMap = D._createAsciiMap(L)), - (this._map = new Map()) - } - static _createAsciiMap(p) { - const L = new Uint8Array(256) - return L.fill(p), L - } - set(p, L) { - const h = (0, R.toUint8)(L) - p >= 0 && p < 256 ? (this._asciiMap[p] = h) : this._map.set(p, h) - } - get(p) { - return p >= 0 && p < 256 ? this._asciiMap[p] : this._map.get(p) || this._defaultValue - } - clear() { - this._asciiMap.fill(this._defaultValue), this._map.clear() - } - } - n.CharacterClassifier = D - class i { - constructor() { - this._actual = new D(0) - } - add(p) { - this._actual.set(p, 1) - } - has(p) { - return this._actual.get(p) === 1 - } - clear() { - return this._actual.clear() - } - } - n.CharacterSet = i - }), - Q(Y[21], X([0, 1, 4]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.LineRange = void 0) - class D { - static joinMany(s) { - if (s.length === 0) return [] - let p = s[0] - for (let L = 1; L < s.length; L++) p = this.join(p, s[L]) - return p - } - static join(s, p) { - if (s.length === 0) return p - if (p.length === 0) return s - const L = [] - let h = 0, - a = 0, - w = null - for (; h < s.length || a < p.length; ) { - let e = null - if (h < s.length && a < p.length) { - const b = s[h], - f = p[a] - b.startLineNumber < f.startLineNumber ? ((e = b), h++) : ((e = f), a++) - } else h < s.length ? ((e = s[h]), h++) : ((e = p[a]), a++) - w === null - ? (w = e) - : w.endLineNumberExclusive >= e.startLineNumber - ? (w = new D( - w.startLineNumber, - Math.max(w.endLineNumberExclusive, e.endLineNumberExclusive) - )) - : (L.push(w), (w = e)) - } - return w !== null && L.push(w), L - } - constructor(s, p) { - if (s > p) - throw new R.BugIndicatingError( - `startLineNumber ${s} cannot be after endLineNumberExclusive ${p}` - ) - ;(this.startLineNumber = s), (this.endLineNumberExclusive = p) - } - contains(s) { - return this.startLineNumber <= s && s < this.endLineNumberExclusive - } - get isEmpty() { - return this.startLineNumber === this.endLineNumberExclusive - } - delta(s) { - return new D(this.startLineNumber + s, this.endLineNumberExclusive + s) - } - get length() { - return this.endLineNumberExclusive - this.startLineNumber - } - join(s) { - return new D( - Math.min(this.startLineNumber, s.startLineNumber), - Math.max(this.endLineNumberExclusive, s.endLineNumberExclusive) - ) - } - toString() { - return `[${this.startLineNumber},${this.endLineNumberExclusive})` - } - intersect(s) { - const p = Math.max(this.startLineNumber, s.startLineNumber), - L = Math.min(this.endLineNumberExclusive, s.endLineNumberExclusive) - if (p <= L) return new D(p, L) - } - overlapOrTouch(s) { - return ( - this.startLineNumber <= s.endLineNumberExclusive && - s.startLineNumber <= this.endLineNumberExclusive - ) - } - equals(s) { - return ( - this.startLineNumber === s.startLineNumber && - this.endLineNumberExclusive === s.endLineNumberExclusive - ) - } - } - n.LineRange = D - }), - Q(Y[6], X([0, 1, 4]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.OffsetRange = void 0) - class D { - static addRange(s, p) { - let L = 0 - for (; L < p.length && p[L].endExclusive < s.start; ) L++ - let h = L - for (; h < p.length && p[h].start <= s.endExclusive; ) h++ - if (L === h) p.splice(L, 0, s) - else { - const a = Math.min(s.start, p[L].start), - w = Math.max(s.endExclusive, p[h - 1].endExclusive) - p.splice(L, h - L, new D(a, w)) - } - } - static tryCreate(s, p) { - if (!(s > p)) return new D(s, p) - } - constructor(s, p) { - if (((this.start = s), (this.endExclusive = p), s > p)) - throw new R.BugIndicatingError(`Invalid range: ${this.toString()}`) - } - get isEmpty() { - return this.start === this.endExclusive - } - delta(s) { - return new D(this.start + s, this.endExclusive + s) - } - get length() { - return this.endExclusive - this.start - } - toString() { - return `[${this.start}, ${this.endExclusive})` - } - equals(s) { - return this.start === s.start && this.endExclusive === s.endExclusive - } - containsRange(s) { - return this.start <= s.start && s.endExclusive <= this.endExclusive - } - join(s) { - return new D(Math.min(this.start, s.start), Math.max(this.endExclusive, s.endExclusive)) - } - intersect(s) { - const p = Math.max(this.start, s.start), - L = Math.min(this.endExclusive, s.endExclusive) - if (p <= L) return new D(p, L) - } - } - n.OffsetRange = D - }), - Q(Y[3], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.Position = void 0) - class R { - constructor(i, s) { - ;(this.lineNumber = i), (this.column = s) - } - with(i = this.lineNumber, s = this.column) { - return i === this.lineNumber && s === this.column ? this : new R(i, s) - } - delta(i = 0, s = 0) { - return this.with(this.lineNumber + i, this.column + s) - } - equals(i) { - return R.equals(this, i) - } - static equals(i, s) { - return !i && !s - ? !0 - : !!i && !!s && i.lineNumber === s.lineNumber && i.column === s.column - } - isBefore(i) { - return R.isBefore(this, i) - } - static isBefore(i, s) { - return i.lineNumber < s.lineNumber - ? !0 - : s.lineNumber < i.lineNumber - ? !1 - : i.column < s.column - } - isBeforeOrEqual(i) { - return R.isBeforeOrEqual(this, i) - } - static isBeforeOrEqual(i, s) { - return i.lineNumber < s.lineNumber - ? !0 - : s.lineNumber < i.lineNumber - ? !1 - : i.column <= s.column - } - static compare(i, s) { - const p = i.lineNumber | 0, - L = s.lineNumber | 0 - if (p === L) { - const h = i.column | 0, - a = s.column | 0 - return h - a - } - return p - L - } - clone() { - return new R(this.lineNumber, this.column) - } - toString() { - return '(' + this.lineNumber + ',' + this.column + ')' - } - static lift(i) { - return new R(i.lineNumber, i.column) - } - static isIPosition(i) { - return i && typeof i.lineNumber == 'number' && typeof i.column == 'number' - } - } - n.Position = R - }), - Q(Y[2], X([0, 1, 3]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.Range = void 0) - class D { - constructor(s, p, L, h) { - s > L || (s === L && p > h) - ? ((this.startLineNumber = L), - (this.startColumn = h), - (this.endLineNumber = s), - (this.endColumn = p)) - : ((this.startLineNumber = s), - (this.startColumn = p), - (this.endLineNumber = L), - (this.endColumn = h)) - } - isEmpty() { - return D.isEmpty(this) - } - static isEmpty(s) { - return s.startLineNumber === s.endLineNumber && s.startColumn === s.endColumn - } - containsPosition(s) { - return D.containsPosition(this, s) - } - static containsPosition(s, p) { - return !( - p.lineNumber < s.startLineNumber || - p.lineNumber > s.endLineNumber || - (p.lineNumber === s.startLineNumber && p.column < s.startColumn) || - (p.lineNumber === s.endLineNumber && p.column > s.endColumn) - ) - } - static strictContainsPosition(s, p) { - return !( - p.lineNumber < s.startLineNumber || - p.lineNumber > s.endLineNumber || - (p.lineNumber === s.startLineNumber && p.column <= s.startColumn) || - (p.lineNumber === s.endLineNumber && p.column >= s.endColumn) - ) - } - containsRange(s) { - return D.containsRange(this, s) - } - static containsRange(s, p) { - return !( - p.startLineNumber < s.startLineNumber || - p.endLineNumber < s.startLineNumber || - p.startLineNumber > s.endLineNumber || - p.endLineNumber > s.endLineNumber || - (p.startLineNumber === s.startLineNumber && p.startColumn < s.startColumn) || - (p.endLineNumber === s.endLineNumber && p.endColumn > s.endColumn) - ) - } - strictContainsRange(s) { - return D.strictContainsRange(this, s) - } - static strictContainsRange(s, p) { - return !( - p.startLineNumber < s.startLineNumber || - p.endLineNumber < s.startLineNumber || - p.startLineNumber > s.endLineNumber || - p.endLineNumber > s.endLineNumber || - (p.startLineNumber === s.startLineNumber && p.startColumn <= s.startColumn) || - (p.endLineNumber === s.endLineNumber && p.endColumn >= s.endColumn) - ) - } - plusRange(s) { - return D.plusRange(this, s) - } - static plusRange(s, p) { - let L, h, a, w - return ( - p.startLineNumber < s.startLineNumber - ? ((L = p.startLineNumber), (h = p.startColumn)) - : p.startLineNumber === s.startLineNumber - ? ((L = p.startLineNumber), (h = Math.min(p.startColumn, s.startColumn))) - : ((L = s.startLineNumber), (h = s.startColumn)), - p.endLineNumber > s.endLineNumber - ? ((a = p.endLineNumber), (w = p.endColumn)) - : p.endLineNumber === s.endLineNumber - ? ((a = p.endLineNumber), (w = Math.max(p.endColumn, s.endColumn))) - : ((a = s.endLineNumber), (w = s.endColumn)), - new D(L, h, a, w) - ) - } - intersectRanges(s) { - return D.intersectRanges(this, s) - } - static intersectRanges(s, p) { - let L = s.startLineNumber, - h = s.startColumn, - a = s.endLineNumber, - w = s.endColumn - const e = p.startLineNumber, - b = p.startColumn, - f = p.endLineNumber, - v = p.endColumn - return ( - L < e ? ((L = e), (h = b)) : L === e && (h = Math.max(h, b)), - a > f ? ((a = f), (w = v)) : a === f && (w = Math.min(w, v)), - L > a || (L === a && h > w) ? null : new D(L, h, a, w) - ) - } - equalsRange(s) { - return D.equalsRange(this, s) - } - static equalsRange(s, p) { - return !s && !p - ? !0 - : !!s && - !!p && - s.startLineNumber === p.startLineNumber && - s.startColumn === p.startColumn && - s.endLineNumber === p.endLineNumber && - s.endColumn === p.endColumn - } - getEndPosition() { - return D.getEndPosition(this) - } - static getEndPosition(s) { - return new R.Position(s.endLineNumber, s.endColumn) - } - getStartPosition() { - return D.getStartPosition(this) - } - static getStartPosition(s) { - return new R.Position(s.startLineNumber, s.startColumn) - } - toString() { - return ( - '[' + - this.startLineNumber + - ',' + - this.startColumn + - ' -> ' + - this.endLineNumber + - ',' + - this.endColumn + - ']' - ) - } - setEndPosition(s, p) { - return new D(this.startLineNumber, this.startColumn, s, p) - } - setStartPosition(s, p) { - return new D(s, p, this.endLineNumber, this.endColumn) - } - collapseToStart() { - return D.collapseToStart(this) - } - static collapseToStart(s) { - return new D(s.startLineNumber, s.startColumn, s.startLineNumber, s.startColumn) - } - collapseToEnd() { - return D.collapseToEnd(this) - } - static collapseToEnd(s) { - return new D(s.endLineNumber, s.endColumn, s.endLineNumber, s.endColumn) - } - delta(s) { - return new D( - this.startLineNumber + s, - this.startColumn, - this.endLineNumber + s, - this.endColumn - ) - } - static fromPositions(s, p = s) { - return new D(s.lineNumber, s.column, p.lineNumber, p.column) - } - static lift(s) { - return s ? new D(s.startLineNumber, s.startColumn, s.endLineNumber, s.endColumn) : null - } - static isIRange(s) { - return ( - s && - typeof s.startLineNumber == 'number' && - typeof s.startColumn == 'number' && - typeof s.endLineNumber == 'number' && - typeof s.endColumn == 'number' - ) - } - static areIntersectingOrTouching(s, p) { - return !( - s.endLineNumber < p.startLineNumber || - (s.endLineNumber === p.startLineNumber && s.endColumn < p.startColumn) || - p.endLineNumber < s.startLineNumber || - (p.endLineNumber === s.startLineNumber && p.endColumn < s.startColumn) - ) - } - static areIntersecting(s, p) { - return !( - s.endLineNumber < p.startLineNumber || - (s.endLineNumber === p.startLineNumber && s.endColumn <= p.startColumn) || - p.endLineNumber < s.startLineNumber || - (p.endLineNumber === s.startLineNumber && p.endColumn <= s.startColumn) - ) - } - static compareRangesUsingStarts(s, p) { - if (s && p) { - const a = s.startLineNumber | 0, - w = p.startLineNumber | 0 - if (a === w) { - const e = s.startColumn | 0, - b = p.startColumn | 0 - if (e === b) { - const f = s.endLineNumber | 0, - v = p.endLineNumber | 0 - if (f === v) { - const g = s.endColumn | 0, - S = p.endColumn | 0 - return g - S - } - return f - v - } - return e - b - } - return a - w - } - return (s ? 1 : 0) - (p ? 1 : 0) - } - static compareRangesUsingEnds(s, p) { - return s.endLineNumber === p.endLineNumber - ? s.endColumn === p.endColumn - ? s.startLineNumber === p.startLineNumber - ? s.startColumn - p.startColumn - : s.startLineNumber - p.startLineNumber - : s.endColumn - p.endColumn - : s.endLineNumber - p.endLineNumber - } - static spansMultipleLines(s) { - return s.endLineNumber > s.startLineNumber - } - toJSON() { - return this - } - } - n.Range = D - }), - Q(Y[33], X([0, 1, 3, 2]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.Selection = void 0) - class i extends D.Range { - constructor(p, L, h, a) { - super(p, L, h, a), - (this.selectionStartLineNumber = p), - (this.selectionStartColumn = L), - (this.positionLineNumber = h), - (this.positionColumn = a) - } - toString() { - return ( - '[' + - this.selectionStartLineNumber + - ',' + - this.selectionStartColumn + - ' -> ' + - this.positionLineNumber + - ',' + - this.positionColumn + - ']' - ) - } - equalsSelection(p) { - return i.selectionsEqual(this, p) - } - static selectionsEqual(p, L) { - return ( - p.selectionStartLineNumber === L.selectionStartLineNumber && - p.selectionStartColumn === L.selectionStartColumn && - p.positionLineNumber === L.positionLineNumber && - p.positionColumn === L.positionColumn - ) - } - getDirection() { - return this.selectionStartLineNumber === this.startLineNumber && - this.selectionStartColumn === this.startColumn - ? 0 - : 1 - } - setEndPosition(p, L) { - return this.getDirection() === 0 - ? new i(this.startLineNumber, this.startColumn, p, L) - : new i(p, L, this.startLineNumber, this.startColumn) - } - getPosition() { - return new R.Position(this.positionLineNumber, this.positionColumn) - } - getSelectionStart() { - return new R.Position(this.selectionStartLineNumber, this.selectionStartColumn) - } - setStartPosition(p, L) { - return this.getDirection() === 0 - ? new i(p, L, this.endLineNumber, this.endColumn) - : new i(this.endLineNumber, this.endColumn, p, L) - } - static fromPositions(p, L = p) { - return new i(p.lineNumber, p.column, L.lineNumber, L.column) - } - static fromRange(p, L) { - return L === 0 - ? new i(p.startLineNumber, p.startColumn, p.endLineNumber, p.endColumn) - : new i(p.endLineNumber, p.endColumn, p.startLineNumber, p.startColumn) - } - static liftSelection(p) { - return new i( - p.selectionStartLineNumber, - p.selectionStartColumn, - p.positionLineNumber, - p.positionColumn - ) - } - static selectionsArrEqual(p, L) { - if ((p && !L) || (!p && L)) return !1 - if (!p && !L) return !0 - if (p.length !== L.length) return !1 - for (let h = 0, a = p.length; h < a; h++) if (!this.selectionsEqual(p[h], L[h])) return !1 - return !0 - } - static isISelection(p) { - return ( - p && - typeof p.selectionStartLineNumber == 'number' && - typeof p.selectionStartColumn == 'number' && - typeof p.positionLineNumber == 'number' && - typeof p.positionColumn == 'number' - ) - } - static createWithDirection(p, L, h, a, w) { - return w === 0 ? new i(p, L, h, a) : new i(h, a, p, L) - } - } - n.Selection = i - }), - Q(Y[34], X([0, 1, 20]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.getMapForWordSeparators = n.WordCharacterClassifier = void 0) - class D extends R.CharacterClassifier { - constructor(p) { - super(0) - for (let L = 0, h = p.length; L < h; L++) this.set(p.charCodeAt(L), 2) - this.set(32, 1), this.set(9, 1) - } - } - n.WordCharacterClassifier = D - function i(s) { - const p = {} - return (L) => (p.hasOwnProperty(L) || (p[L] = s(L)), p[L]) - } - n.getMapForWordSeparators = i((s) => new D(s)) - }), - Q(Y[22], X([0, 1, 15, 16]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.getWordAtText = - n.ensureValidWordDefinition = - n.DEFAULT_WORD_REGEXP = - n.USUAL_WORD_SEPARATORS = - void 0), - (n.USUAL_WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?') - function i(a = '') { - let w = '(-?\\d*\\.\\d\\w*)|([^' - for (const e of n.USUAL_WORD_SEPARATORS) a.indexOf(e) >= 0 || (w += '\\' + e) - return (w += '\\s]+)'), new RegExp(w, 'g') - } - n.DEFAULT_WORD_REGEXP = i() - function s(a) { - let w = n.DEFAULT_WORD_REGEXP - if (a && a instanceof RegExp) - if (a.global) w = a - else { - let e = 'g' - a.ignoreCase && (e += 'i'), - a.multiline && (e += 'm'), - a.unicode && (e += 'u'), - (w = new RegExp(a.source, e)) - } - return (w.lastIndex = 0), w - } - n.ensureValidWordDefinition = s - const p = new D.LinkedList() - p.unshift({ maxLen: 1e3, windowSize: 15, timeBudget: 150 }) - function L(a, w, e, b, f) { - if ((f || (f = R.Iterable.first(p)), e.length > f.maxLen)) { - let y = a - f.maxLen / 2 - return ( - y < 0 ? (y = 0) : (b += y), (e = e.substring(y, a + f.maxLen / 2)), L(a, w, e, b, f) - ) - } - const v = Date.now(), - g = a - 1 - b - let S = -1, - E = null - for (let y = 1; !(Date.now() - v >= f.timeBudget); y++) { - const _ = g - f.windowSize * y - w.lastIndex = Math.max(0, _) - const d = h(w, e, g, S) - if ((!d && E) || ((E = d), _ <= 0)) break - S = _ - } - if (E) { - const y = { - word: E[0], - startColumn: b + 1 + E.index, - endColumn: b + 1 + E.index + E[0].length, - } - return (w.lastIndex = 0), y - } - return null - } - n.getWordAtText = L - function h(a, w, e, b) { - let f - for (; (f = a.exec(w)); ) { - const v = f.index || 0 - if (v <= e && a.lastIndex >= e) return f - if (b > 0 && v > b) return null - } - return null - } - }), - Q(Y[7], X([0, 1, 4, 6]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.DateTimeout = n.InfiniteTimeout = n.SequenceDiff = n.DiffAlgorithmResult = void 0) - class i { - static trivial(a, w) { - return new i([new s(new D.OffsetRange(0, a.length), new D.OffsetRange(0, w.length))], !1) - } - static trivialTimedOut(a, w) { - return new i([new s(new D.OffsetRange(0, a.length), new D.OffsetRange(0, w.length))], !0) - } - constructor(a, w) { - ;(this.diffs = a), (this.hitTimeout = w) - } - } - n.DiffAlgorithmResult = i - class s { - constructor(a, w) { - ;(this.seq1Range = a), (this.seq2Range = w) - } - reverse() { - return new s(this.seq2Range, this.seq1Range) - } - toString() { - return `${this.seq1Range} <-> ${this.seq2Range}` - } - join(a) { - return new s(this.seq1Range.join(a.seq1Range), this.seq2Range.join(a.seq2Range)) - } - } - n.SequenceDiff = s - class p { - isValid() { - return !0 - } - } - ;(p.instance = new p()), (n.InfiniteTimeout = p) - class L { - constructor(a) { - if (((this.timeout = a), (this.startTime = Date.now()), (this.valid = !0), a <= 0)) - throw new R.BugIndicatingError('timeout must be positive') - } - isValid() { - if (!(Date.now() - this.startTime < this.timeout) && this.valid) { - this.valid = !1 - debugger - } - return this.valid - } - } - n.DateTimeout = L - }), - Q(Y[35], X([0, 1, 6, 7]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.shiftSequenceDiffs = - n.joinSequenceDiffs = - n.smoothenSequenceDiffs = - n.optimizeSequenceDiffs = - void 0) - function i(a, w, e) { - let b = e - return (b = p(a, w, b)), (b = L(a, w, b)), b - } - n.optimizeSequenceDiffs = i - function s(a, w, e) { - const b = [] - for (const f of e) { - const v = b[b.length - 1] - if (!v) { - b.push(f) - continue - } - f.seq1Range.start - v.seq1Range.endExclusive <= 2 || - f.seq2Range.start - v.seq2Range.endExclusive <= 2 - ? (b[b.length - 1] = new D.SequenceDiff( - v.seq1Range.join(f.seq1Range), - v.seq2Range.join(f.seq2Range) - )) - : b.push(f) - } - return b - } - n.smoothenSequenceDiffs = s - function p(a, w, e) { - const b = [] - e.length > 0 && b.push(e[0]) - for (let f = 1; f < e.length; f++) { - const v = b[b.length - 1], - g = e[f] - if (g.seq1Range.isEmpty) { - let S = !0 - const E = g.seq1Range.start - v.seq1Range.endExclusive - for (let y = 1; y <= E; y++) - if ( - w.getElement(g.seq2Range.start - y) !== w.getElement(g.seq2Range.endExclusive - y) - ) { - S = !1 - break - } - if (S) { - b[b.length - 1] = new D.SequenceDiff( - v.seq1Range, - new R.OffsetRange(v.seq2Range.start, g.seq2Range.endExclusive - E) - ) - continue - } - } - b.push(g) - } - return b - } - n.joinSequenceDiffs = p - function L(a, w, e) { - if (!a.getBoundaryScore || !w.getBoundaryScore) return e - for (let b = 0; b < e.length; b++) { - const f = e[b] - if (f.seq1Range.isEmpty) { - const v = b > 0 ? e[b - 1].seq2Range.endExclusive : -1, - g = b + 1 < e.length ? e[b + 1].seq2Range.start : w.length - e[b] = h(f, a, w, g, v) - } else if (f.seq2Range.isEmpty) { - const v = b > 0 ? e[b - 1].seq1Range.endExclusive : -1, - g = b + 1 < e.length ? e[b + 1].seq1Range.start : a.length - e[b] = h(f.reverse(), w, a, g, v).reverse() - } - } - return e - } - n.shiftSequenceDiffs = L - function h(a, w, e, b, f) { - let g = 1 - for ( - ; - a.seq2Range.start - g > f && - e.getElement(a.seq2Range.start - g) === e.getElement(a.seq2Range.endExclusive - g) && - g < 20; - - ) - g++ - g-- - let S = 0 - for ( - ; - a.seq2Range.start + S < b && - e.getElement(a.seq2Range.start + S) === e.getElement(a.seq2Range.endExclusive + S) && - S < 20; - - ) - S++ - if (g === 0 && S === 0) return a - let E = 0, - y = -1 - for (let _ = -g; _ <= S; _++) { - const d = a.seq2Range.start + _, - C = a.seq2Range.endExclusive + _, - r = a.seq1Range.start + _, - u = w.getBoundaryScore(r) + e.getBoundaryScore(d) + e.getBoundaryScore(C) - u > y && ((y = u), (E = _)) - } - return E !== 0 ? new D.SequenceDiff(a.seq1Range.delta(E), a.seq2Range.delta(E)) : a - } - }), - Q(Y[36], X([0, 1, 6, 7]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.MyersDiffAlgorithm = void 0) - class i { - compute(a, w, e = D.InfiniteTimeout.instance) { - if (a.length === 0 || w.length === 0) return D.DiffAlgorithmResult.trivial(a, w) - function b(C, r) { - for (; C < a.length && r < w.length && a.getElement(C) === w.getElement(r); ) C++, r++ - return C - } - let f = 0 - const v = new p() - v.set(0, b(0, 0)) - const g = new L() - g.set(0, v.get(0) === 0 ? null : new s(null, 0, 0, v.get(0))) - let S = 0 - e: for (;;) - for (f++, S = -f; S <= f; S += 2) { - if (!e.isValid()) return D.DiffAlgorithmResult.trivialTimedOut(a, w) - const C = S === f ? -1 : v.get(S + 1), - r = S === -f ? -1 : v.get(S - 1) + 1, - u = Math.min(Math.max(C, r), a.length), - o = u - S, - c = b(u, o) - v.set(S, c) - const l = u === C ? g.get(S + 1) : g.get(S - 1) - if ( - (g.set(S, c !== u ? new s(l, u, o, c - u) : l), - v.get(S) === a.length && v.get(S) - S === w.length) - ) - break e - } - let E = g.get(S) - const y = [] - let _ = a.length, - d = w.length - for (;;) { - const C = E ? E.x + E.length : 0, - r = E ? E.y + E.length : 0 - if ( - ((C !== _ || r !== d) && - y.push(new D.SequenceDiff(new R.OffsetRange(C, _), new R.OffsetRange(r, d))), - !E) - ) - break - ;(_ = E.x), (d = E.y), (E = E.prev) - } - return y.reverse(), new D.DiffAlgorithmResult(y, !1) - } - } - n.MyersDiffAlgorithm = i - class s { - constructor(a, w, e, b) { - ;(this.prev = a), (this.x = w), (this.y = e), (this.length = b) - } - } - class p { - constructor() { - ;(this.positiveArr = new Int32Array(10)), (this.negativeArr = new Int32Array(10)) - } - get(a) { - return a < 0 ? ((a = -a - 1), this.negativeArr[a]) : this.positiveArr[a] - } - set(a, w) { - if (a < 0) { - if (((a = -a - 1), a >= this.negativeArr.length)) { - const e = this.negativeArr - ;(this.negativeArr = new Int32Array(e.length * 2)), this.negativeArr.set(e) - } - this.negativeArr[a] = w - } else { - if (a >= this.positiveArr.length) { - const e = this.positiveArr - ;(this.positiveArr = new Int32Array(e.length * 2)), this.positiveArr.set(e) - } - this.positiveArr[a] = w - } - } - } - class L { - constructor() { - ;(this.positiveArr = []), (this.negativeArr = []) - } - get(a) { - return a < 0 ? ((a = -a - 1), this.negativeArr[a]) : this.positiveArr[a] - } - set(a, w) { - a < 0 ? ((a = -a - 1), (this.negativeArr[a] = w)) : (this.positiveArr[a] = w) - } - } - }), - Q(Y[37], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.Array2D = void 0) - class R { - constructor(i, s) { - ;(this.width = i), (this.height = s), (this.array = []), (this.array = new Array(i * s)) - } - get(i, s) { - return this.array[i + s * this.width] - } - set(i, s, p) { - this.array[i + s * this.width] = p - } - } - n.Array2D = R - }), - Q(Y[38], X([0, 1, 6, 7, 37]), function (x, n, R, D, i) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.DynamicProgrammingDiffing = void 0) - class s { - compute(L, h, a = D.InfiniteTimeout.instance, w) { - if (L.length === 0 || h.length === 0) return D.DiffAlgorithmResult.trivial(L, h) - const e = new i.Array2D(L.length, h.length), - b = new i.Array2D(L.length, h.length), - f = new i.Array2D(L.length, h.length) - for (let d = 0; d < L.length; d++) - for (let C = 0; C < h.length; C++) { - if (!a.isValid()) return D.DiffAlgorithmResult.trivialTimedOut(L, h) - const r = d === 0 ? 0 : e.get(d - 1, C), - u = C === 0 ? 0 : e.get(d, C - 1) - let o - L.getElement(d) === h.getElement(C) - ? (d === 0 || C === 0 ? (o = 0) : (o = e.get(d - 1, C - 1)), - d > 0 && C > 0 && b.get(d - 1, C - 1) === 3 && (o += f.get(d - 1, C - 1)), - (o += w ? w(d, C) : 1)) - : (o = -1) - const c = Math.max(r, u, o) - if (c === o) { - const l = d > 0 && C > 0 ? f.get(d - 1, C - 1) : 0 - f.set(d, C, l + 1), b.set(d, C, 3) - } else - c === r - ? (f.set(d, C, 0), b.set(d, C, 1)) - : c === u && (f.set(d, C, 0), b.set(d, C, 2)) - e.set(d, C, c) - } - const v = [] - let g = L.length, - S = h.length - function E(d, C) { - ;(d + 1 !== g || C + 1 !== S) && - v.push(new D.SequenceDiff(new R.OffsetRange(d + 1, g), new R.OffsetRange(C + 1, S))), - (g = d), - (S = C) - } - let y = L.length - 1, - _ = h.length - 1 - for (; y >= 0 && _ >= 0; ) - b.get(y, _) === 3 ? (E(y, _), y--, _--) : b.get(y, _) === 1 ? y-- : _-- - return E(-1, -1), v.reverse(), new D.DiffAlgorithmResult(v, !1) - } - } - n.DynamicProgrammingDiffing = s - }), - Q(Y[23], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.RangeMapping = n.LineRangeMapping = n.LinesDiff = void 0) - class R { - constructor(p, L) { - ;(this.changes = p), (this.hitTimeout = L) - } - } - n.LinesDiff = R - class D { - constructor(p, L, h) { - ;(this.originalRange = p), (this.modifiedRange = L), (this.innerChanges = h) - } - toString() { - return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}` - } - } - n.LineRangeMapping = D - class i { - constructor(p, L) { - ;(this.originalRange = p), (this.modifiedRange = L) - } - toString() { - return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}` - } - } - n.RangeMapping = i - }), - Q(Y[39], X([0, 1, 17, 23, 5, 2, 10, 21]), function (x, n, R, D, i, s, p, L) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.DiffComputer = n.SmartLinesDiffComputer = void 0) - const h = 3 - class a { - computeDiff(C, r, u) { - var o - const l = new S(C, r, { - maxComputationTime: u.maxComputationTimeMs, - shouldIgnoreTrimWhitespace: u.ignoreTrimWhitespace, - shouldComputeCharChanges: !0, - shouldMakePrettyDiff: !0, - shouldPostProcessCharChanges: !0, - }).computeDiff(), - m = [] - let N = null - for (const A of l.changes) { - let M - A.originalEndLineNumber === 0 - ? (M = new L.LineRange(A.originalStartLineNumber + 1, A.originalStartLineNumber + 1)) - : (M = new L.LineRange(A.originalStartLineNumber, A.originalEndLineNumber + 1)) - let k - A.modifiedEndLineNumber === 0 - ? (k = new L.LineRange(A.modifiedStartLineNumber + 1, A.modifiedStartLineNumber + 1)) - : (k = new L.LineRange(A.modifiedStartLineNumber, A.modifiedEndLineNumber + 1)) - let q = new D.LineRangeMapping( - M, - k, - (o = A.charChanges) === null || o === void 0 - ? void 0 - : o.map( - (I) => - new D.RangeMapping( - new s.Range( - I.originalStartLineNumber, - I.originalStartColumn, - I.originalEndLineNumber, - I.originalEndColumn - ), - new s.Range( - I.modifiedStartLineNumber, - I.modifiedStartColumn, - I.modifiedEndLineNumber, - I.modifiedEndColumn - ) - ) - ) - ) - N && - (N.modifiedRange.endLineNumberExclusive === q.modifiedRange.startLineNumber || - N.originalRange.endLineNumberExclusive === q.originalRange.startLineNumber) && - ((q = new D.LineRangeMapping( - N.originalRange.join(q.originalRange), - N.modifiedRange.join(q.modifiedRange), - N.innerChanges && q.innerChanges ? N.innerChanges.concat(q.innerChanges) : void 0 - )), - m.pop()), - m.push(q), - (N = q) - } - return ( - (0, p.assertFn)(() => - (0, p.checkAdjacentItems)( - m, - (A, M) => - M.originalRange.startLineNumber - A.originalRange.endLineNumberExclusive === - M.modifiedRange.startLineNumber - A.modifiedRange.endLineNumberExclusive && - A.originalRange.endLineNumberExclusive < M.originalRange.startLineNumber && - A.modifiedRange.endLineNumberExclusive < M.modifiedRange.startLineNumber - ) - ), - new D.LinesDiff(m, l.quitEarly) - ) - } - } - n.SmartLinesDiffComputer = a - function w(d, C, r, u) { - return new R.LcsDiff(d, C, r).ComputeDiff(u) - } - class e { - constructor(C) { - const r = [], - u = [] - for (let o = 0, c = C.length; o < c; o++) (r[o] = E(C[o], 1)), (u[o] = y(C[o], 1)) - ;(this.lines = C), (this._startColumns = r), (this._endColumns = u) - } - getElements() { - const C = [] - for (let r = 0, u = this.lines.length; r < u; r++) - C[r] = this.lines[r].substring(this._startColumns[r] - 1, this._endColumns[r] - 1) - return C - } - getStrictElement(C) { - return this.lines[C] - } - getStartLineNumber(C) { - return C + 1 - } - getEndLineNumber(C) { - return C + 1 - } - createCharSequence(C, r, u) { - const o = [], - c = [], - l = [] - let m = 0 - for (let N = r; N <= u; N++) { - const A = this.lines[N], - M = C ? this._startColumns[N] : 1, - k = C ? this._endColumns[N] : A.length + 1 - for (let q = M; q < k; q++) - (o[m] = A.charCodeAt(q - 1)), (c[m] = N + 1), (l[m] = q), m++ - !C && N < u && ((o[m] = 10), (c[m] = N + 1), (l[m] = A.length + 1), m++) - } - return new b(o, c, l) - } - } - class b { - constructor(C, r, u) { - ;(this._charCodes = C), (this._lineNumbers = r), (this._columns = u) - } - toString() { - return ( - '[' + - this._charCodes - .map( - (C, r) => - (C === 10 ? '\\n' : String.fromCharCode(C)) + - `-(${this._lineNumbers[r]},${this._columns[r]})` - ) - .join(', ') + - ']' - ) - } - _assertIndex(C, r) { - if (C < 0 || C >= r.length) throw new Error('Illegal index') - } - getElements() { - return this._charCodes - } - getStartLineNumber(C) { - return C > 0 && C === this._lineNumbers.length - ? this.getEndLineNumber(C - 1) - : (this._assertIndex(C, this._lineNumbers), this._lineNumbers[C]) - } - getEndLineNumber(C) { - return C === -1 - ? this.getStartLineNumber(C + 1) - : (this._assertIndex(C, this._lineNumbers), - this._charCodes[C] === 10 ? this._lineNumbers[C] + 1 : this._lineNumbers[C]) - } - getStartColumn(C) { - return C > 0 && C === this._columns.length - ? this.getEndColumn(C - 1) - : (this._assertIndex(C, this._columns), this._columns[C]) - } - getEndColumn(C) { - return C === -1 - ? this.getStartColumn(C + 1) - : (this._assertIndex(C, this._columns), - this._charCodes[C] === 10 ? 1 : this._columns[C] + 1) - } - } - class f { - constructor(C, r, u, o, c, l, m, N) { - ;(this.originalStartLineNumber = C), - (this.originalStartColumn = r), - (this.originalEndLineNumber = u), - (this.originalEndColumn = o), - (this.modifiedStartLineNumber = c), - (this.modifiedStartColumn = l), - (this.modifiedEndLineNumber = m), - (this.modifiedEndColumn = N) - } - static createFromDiffChange(C, r, u) { - const o = r.getStartLineNumber(C.originalStart), - c = r.getStartColumn(C.originalStart), - l = r.getEndLineNumber(C.originalStart + C.originalLength - 1), - m = r.getEndColumn(C.originalStart + C.originalLength - 1), - N = u.getStartLineNumber(C.modifiedStart), - A = u.getStartColumn(C.modifiedStart), - M = u.getEndLineNumber(C.modifiedStart + C.modifiedLength - 1), - k = u.getEndColumn(C.modifiedStart + C.modifiedLength - 1) - return new f(o, c, l, m, N, A, M, k) - } - } - function v(d) { - if (d.length <= 1) return d - const C = [d[0]] - let r = C[0] - for (let u = 1, o = d.length; u < o; u++) { - const c = d[u], - l = c.originalStart - (r.originalStart + r.originalLength), - m = c.modifiedStart - (r.modifiedStart + r.modifiedLength) - Math.min(l, m) < h - ? ((r.originalLength = c.originalStart + c.originalLength - r.originalStart), - (r.modifiedLength = c.modifiedStart + c.modifiedLength - r.modifiedStart)) - : (C.push(c), (r = c)) - } - return C - } - class g { - constructor(C, r, u, o, c) { - ;(this.originalStartLineNumber = C), - (this.originalEndLineNumber = r), - (this.modifiedStartLineNumber = u), - (this.modifiedEndLineNumber = o), - (this.charChanges = c) - } - static createFromDiffResult(C, r, u, o, c, l, m) { - let N, A, M, k, q - if ( - (r.originalLength === 0 - ? ((N = u.getStartLineNumber(r.originalStart) - 1), (A = 0)) - : ((N = u.getStartLineNumber(r.originalStart)), - (A = u.getEndLineNumber(r.originalStart + r.originalLength - 1))), - r.modifiedLength === 0 - ? ((M = o.getStartLineNumber(r.modifiedStart) - 1), (k = 0)) - : ((M = o.getStartLineNumber(r.modifiedStart)), - (k = o.getEndLineNumber(r.modifiedStart + r.modifiedLength - 1))), - l && - r.originalLength > 0 && - r.originalLength < 20 && - r.modifiedLength > 0 && - r.modifiedLength < 20 && - c()) - ) { - const I = u.createCharSequence( - C, - r.originalStart, - r.originalStart + r.originalLength - 1 - ), - B = o.createCharSequence(C, r.modifiedStart, r.modifiedStart + r.modifiedLength - 1) - if (I.getElements().length > 0 && B.getElements().length > 0) { - let H = w(I, B, c, !0).changes - m && (H = v(H)), (q = []) - for (let F = 0, U = H.length; F < U; F++) q.push(f.createFromDiffChange(H[F], I, B)) - } - } - return new g(N, A, M, k, q) - } - } - class S { - constructor(C, r, u) { - ;(this.shouldComputeCharChanges = u.shouldComputeCharChanges), - (this.shouldPostProcessCharChanges = u.shouldPostProcessCharChanges), - (this.shouldIgnoreTrimWhitespace = u.shouldIgnoreTrimWhitespace), - (this.shouldMakePrettyDiff = u.shouldMakePrettyDiff), - (this.originalLines = C), - (this.modifiedLines = r), - (this.original = new e(C)), - (this.modified = new e(r)), - (this.continueLineDiff = _(u.maxComputationTime)), - (this.continueCharDiff = _( - u.maxComputationTime === 0 ? 0 : Math.min(u.maxComputationTime, 5e3) - )) - } - computeDiff() { - if (this.original.lines.length === 1 && this.original.lines[0].length === 0) - return this.modified.lines.length === 1 && this.modified.lines[0].length === 0 - ? { quitEarly: !1, changes: [] } - : { - quitEarly: !1, - changes: [ - { - originalStartLineNumber: 1, - originalEndLineNumber: 1, - modifiedStartLineNumber: 1, - modifiedEndLineNumber: this.modified.lines.length, - charChanges: void 0, - }, - ], - } - if (this.modified.lines.length === 1 && this.modified.lines[0].length === 0) - return { - quitEarly: !1, - changes: [ - { - originalStartLineNumber: 1, - originalEndLineNumber: this.original.lines.length, - modifiedStartLineNumber: 1, - modifiedEndLineNumber: 1, - charChanges: void 0, - }, - ], - } - const C = w( - this.original, - this.modified, - this.continueLineDiff, - this.shouldMakePrettyDiff - ), - r = C.changes, - u = C.quitEarly - if (this.shouldIgnoreTrimWhitespace) { - const m = [] - for (let N = 0, A = r.length; N < A; N++) - m.push( - g.createFromDiffResult( - this.shouldIgnoreTrimWhitespace, - r[N], - this.original, - this.modified, - this.continueCharDiff, - this.shouldComputeCharChanges, - this.shouldPostProcessCharChanges - ) - ) - return { quitEarly: u, changes: m } - } - const o = [] - let c = 0, - l = 0 - for (let m = -1, N = r.length; m < N; m++) { - const A = m + 1 < N ? r[m + 1] : null, - M = A ? A.originalStart : this.originalLines.length, - k = A ? A.modifiedStart : this.modifiedLines.length - for (; c < M && l < k; ) { - const q = this.originalLines[c], - I = this.modifiedLines[l] - if (q !== I) { - { - let B = E(q, 1), - H = E(I, 1) - for (; B > 1 && H > 1; ) { - const F = q.charCodeAt(B - 2), - U = I.charCodeAt(H - 2) - if (F !== U) break - B--, H-- - } - ;(B > 1 || H > 1) && - this._pushTrimWhitespaceCharChange(o, c + 1, 1, B, l + 1, 1, H) - } - { - let B = y(q, 1), - H = y(I, 1) - const F = q.length + 1, - U = I.length + 1 - for (; B < F && H < U; ) { - const T = q.charCodeAt(B - 1), - W = q.charCodeAt(H - 1) - if (T !== W) break - B++, H++ - } - ;(B < F || H < U) && - this._pushTrimWhitespaceCharChange(o, c + 1, B, F, l + 1, H, U) - } - } - c++, l++ - } - A && - (o.push( - g.createFromDiffResult( - this.shouldIgnoreTrimWhitespace, - A, - this.original, - this.modified, - this.continueCharDiff, - this.shouldComputeCharChanges, - this.shouldPostProcessCharChanges - ) - ), - (c += A.originalLength), - (l += A.modifiedLength)) - } - return { quitEarly: u, changes: o } - } - _pushTrimWhitespaceCharChange(C, r, u, o, c, l, m) { - if (this._mergeTrimWhitespaceCharChange(C, r, u, o, c, l, m)) return - let N - this.shouldComputeCharChanges && (N = [new f(r, u, r, o, c, l, c, m)]), - C.push(new g(r, r, c, c, N)) - } - _mergeTrimWhitespaceCharChange(C, r, u, o, c, l, m) { - const N = C.length - if (N === 0) return !1 - const A = C[N - 1] - return A.originalEndLineNumber === 0 || A.modifiedEndLineNumber === 0 - ? !1 - : A.originalEndLineNumber === r && A.modifiedEndLineNumber === c - ? (this.shouldComputeCharChanges && - A.charChanges && - A.charChanges.push(new f(r, u, r, o, c, l, c, m)), - !0) - : A.originalEndLineNumber + 1 === r && A.modifiedEndLineNumber + 1 === c - ? ((A.originalEndLineNumber = r), - (A.modifiedEndLineNumber = c), - this.shouldComputeCharChanges && - A.charChanges && - A.charChanges.push(new f(r, u, r, o, c, l, c, m)), - !0) - : !1 - } - } - n.DiffComputer = S - function E(d, C) { - const r = i.firstNonWhitespaceIndex(d) - return r === -1 ? C : r + 1 - } - function y(d, C) { - const r = i.lastNonWhitespaceIndex(d) - return r === -1 ? C : r + 2 - } - function _(d) { - if (d === 0) return () => !0 - const C = Date.now() - return () => Date.now() - C < d - } - }), - Q( - Y[40], - X([0, 1, 10, 21, 6, 3, 2, 7, 38, 35, 36, 23]), - function (x, n, R, D, i, s, p, L, h, a, w, e) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.LineSequence = - n.getLineRangeMapping = - n.lineRangeMappingFromRangeMappings = - n.StandardLinesDiffComputer = - void 0) - class b { - constructor() { - ;(this.dynamicProgrammingDiffing = new h.DynamicProgrammingDiffing()), - (this.myersDiffingAlgorithm = new w.MyersDiffAlgorithm()) - } - computeDiff(m, N, A) { - const M = - A.maxComputationTimeMs === 0 - ? L.InfiniteTimeout.instance - : new L.DateTimeout(A.maxComputationTimeMs), - k = !A.ignoreTrimWhitespace, - q = new Map() - function I(re) { - let se = q.get(re) - return se === void 0 && ((se = q.size), q.set(re, se)), se - } - const B = m.map((re) => I(re.trim())), - H = N.map((re) => I(re.trim())), - F = new y(B, m), - U = new y(H, N), - T = (() => - F.length + U.length < 1500 - ? this.dynamicProgrammingDiffing.compute(F, U, M, (re, se) => - m[re] === N[se] - ? N[se].length === 0 - ? 0.1 - : 1 + Math.log(1 + N[se].length) - : 0.99 - ) - : this.myersDiffingAlgorithm.compute(F, U))() - let W = T.diffs, - t = T.hitTimeout - W = (0, a.optimizeSequenceDiffs)(F, U, W) - const te = [], - ie = (re) => { - if (!!k) - for (let se = 0; se < re; se++) { - const ge = ue + se, - Le = de + se - if (m[ge] !== N[Le]) { - const J = this.refineDiff( - m, - N, - new L.SequenceDiff( - new i.OffsetRange(ge, ge + 1), - new i.OffsetRange(Le, Le + 1) - ), - M, - k - ) - for (const z of J.mappings) te.push(z) - J.hitTimeout && (t = !0) - } - } - } - let ue = 0, - de = 0 - for (const re of W) { - ;(0, R.assertFn)(() => re.seq1Range.start - ue === re.seq2Range.start - de) - const se = re.seq1Range.start - ue - ie(se), (ue = re.seq1Range.endExclusive), (de = re.seq2Range.endExclusive) - const ge = this.refineDiff(m, N, re, M, k) - ge.hitTimeout && (t = !0) - for (const Le of ge.mappings) te.push(Le) - } - ie(m.length - ue) - const Ce = g(te, m, N) - return new e.LinesDiff(Ce, t) - } - refineDiff(m, N, A, M, k) { - const q = new d(m, A.seq1Range, k), - I = new d(N, A.seq2Range, k), - B = - q.length + I.length < 500 - ? this.dynamicProgrammingDiffing.compute(q, I, M) - : this.myersDiffingAlgorithm.compute(q, I, M) - let H = B.diffs - return ( - (H = (0, a.optimizeSequenceDiffs)(q, I, H)), - (H = f(q, I, H)), - (H = (0, a.smoothenSequenceDiffs)(q, I, H)), - { - mappings: H.map( - (U) => - new e.RangeMapping(q.translateRange(U.seq1Range), I.translateRange(U.seq2Range)) - ), - hitTimeout: B.hitTimeout, - } - ) - } - } - n.StandardLinesDiffComputer = b - function f(l, m, N) { - const A = [] - let M - function k() { - if (!M) return - const I = M.s1Range.length - M.deleted, - B = M.s2Range.length - M.added - Math.max(M.deleted, M.added) + (M.count - 1) > I && - A.push(new L.SequenceDiff(M.s1Range, M.s2Range)), - (M = void 0) - } - for (const I of N) { - let B = function (W, t) { - var te, ie, ue, de - if (!M || !M.s1Range.containsRange(W) || !M.s2Range.containsRange(t)) - if (M && !(M.s1Range.endExclusive < W.start && M.s2Range.endExclusive < t.start)) { - const se = i.OffsetRange.tryCreate(M.s1Range.endExclusive, W.start), - ge = i.OffsetRange.tryCreate(M.s2Range.endExclusive, t.start) - ;(M.deleted += (te = se?.length) !== null && te !== void 0 ? te : 0), - (M.added += (ie = ge?.length) !== null && ie !== void 0 ? ie : 0), - (M.s1Range = M.s1Range.join(W)), - (M.s2Range = M.s2Range.join(t)) - } else k(), (M = { added: 0, deleted: 0, count: 0, s1Range: W, s2Range: t }) - const Ce = W.intersect(I.seq1Range), - re = t.intersect(I.seq2Range) - M.count++, - (M.deleted += (ue = Ce?.length) !== null && ue !== void 0 ? ue : 0), - (M.added += (de = re?.length) !== null && de !== void 0 ? de : 0) - } - const H = l.findWordContaining(I.seq1Range.start - 1), - F = m.findWordContaining(I.seq2Range.start - 1), - U = l.findWordContaining(I.seq1Range.endExclusive), - T = m.findWordContaining(I.seq2Range.endExclusive) - H && U && F && T && H.equals(U) && F.equals(T) - ? B(H, F) - : (H && F && B(H, F), U && T && B(U, T)) - } - return k(), v(N, A) - } - function v(l, m) { - const N = [] - for (; l.length > 0 || m.length > 0; ) { - const A = l[0], - M = m[0] - let k - A && (!M || A.seq1Range.start < M.seq1Range.start) ? (k = l.shift()) : (k = m.shift()), - N.length > 0 && N[N.length - 1].seq1Range.endExclusive >= k.seq1Range.start - ? (N[N.length - 1] = N[N.length - 1].join(k)) - : N.push(k) - } - return N - } - function g(l, m, N) { - const A = [] - for (const M of E( - l.map((k) => S(k, m, N)), - (k, q) => - k.originalRange.overlapOrTouch(q.originalRange) || - k.modifiedRange.overlapOrTouch(q.modifiedRange) - )) { - const k = M[0], - q = M[M.length - 1] - A.push( - new e.LineRangeMapping( - k.originalRange.join(q.originalRange), - k.modifiedRange.join(q.modifiedRange), - M.map((I) => I.innerChanges[0]) - ) - ) - } - return ( - (0, R.assertFn)(() => - (0, R.checkAdjacentItems)( - A, - (M, k) => - k.originalRange.startLineNumber - M.originalRange.endLineNumberExclusive === - k.modifiedRange.startLineNumber - M.modifiedRange.endLineNumberExclusive && - M.originalRange.endLineNumberExclusive < k.originalRange.startLineNumber && - M.modifiedRange.endLineNumberExclusive < k.modifiedRange.startLineNumber - ) - ), - A - ) - } - n.lineRangeMappingFromRangeMappings = g - function S(l, m, N) { - let A = 0, - M = 0 - l.modifiedRange.startColumn - 1 >= N[l.modifiedRange.startLineNumber - 1].length && - l.originalRange.startColumn - 1 >= m[l.originalRange.startLineNumber - 1].length && - (A = 1), - l.modifiedRange.endColumn === 1 && - l.originalRange.endColumn === 1 && - l.originalRange.startLineNumber + A <= l.originalRange.endLineNumber && - l.modifiedRange.startLineNumber + A <= l.modifiedRange.endLineNumber && - (M = -1) - const k = new D.LineRange( - l.originalRange.startLineNumber + A, - l.originalRange.endLineNumber + 1 + M - ), - q = new D.LineRange( - l.modifiedRange.startLineNumber + A, - l.modifiedRange.endLineNumber + 1 + M - ) - return new e.LineRangeMapping(k, q, [l]) - } - n.getLineRangeMapping = S - function* E(l, m) { - let N, A - for (const M of l) - A !== void 0 && m(A, M) ? N.push(M) : (N && (yield N), (N = [M])), (A = M) - N && (yield N) - } - class y { - constructor(m, N) { - ;(this.trimmedHash = m), (this.lines = N) - } - getElement(m) { - return this.trimmedHash[m] - } - get length() { - return this.trimmedHash.length - } - getBoundaryScore(m) { - const N = m === 0 ? 0 : _(this.lines[m - 1]), - A = m === this.lines.length ? 0 : _(this.lines[m]) - return 1e3 - (N + A) - } - } - n.LineSequence = y - function _(l) { - let m = 0 - for (; m < l.length && (l.charCodeAt(m) === 32 || l.charCodeAt(m) === 9); ) m++ - return m - } - class d { - constructor(m, N, A) { - ;(this.lines = m), - (this.considerWhitespaceChanges = A), - (this.elements = []), - (this.firstCharOffsetByLineMinusOne = []), - (this.offsetByLine = []) - let M = !1 - N.start > 0 && - N.endExclusive >= m.length && - ((N = new i.OffsetRange(N.start - 1, N.endExclusive)), (M = !0)), - (this.lineRange = N) - for (let k = this.lineRange.start; k < this.lineRange.endExclusive; k++) { - let q = m[k], - I = 0 - if (M) (I = q.length), (q = ''), (M = !1) - else if (!A) { - const B = q.trimStart() - ;(I = q.length - B.length), (q = B.trimEnd()) - } - this.offsetByLine.push(I) - for (let B = 0; B < q.length; B++) this.elements.push(q.charCodeAt(B)) - k < m.length - 1 && - (this.elements.push( - ` -`.charCodeAt(0) - ), - (this.firstCharOffsetByLineMinusOne[k - this.lineRange.start] = - this.elements.length)) - } - this.offsetByLine.push(0) - } - toString() { - return `Slice: "${this.text}"` - } - get text() { - return [...this.elements].map((m) => String.fromCharCode(m)).join('') - } - getElement(m) { - return this.elements[m] - } - get length() { - return this.elements.length - } - getBoundaryScore(m) { - const N = o(m > 0 ? this.elements[m - 1] : -1), - A = o(m < this.elements.length ? this.elements[m] : -1) - if (N === 6 && A === 7) return 0 - let M = 0 - return N !== A && ((M += 10), A === 1 && (M += 1)), (M += u(N)), (M += u(A)), M - } - translateOffset(m) { - if (this.lineRange.isEmpty) return new s.Position(this.lineRange.start + 1, 1) - let N = 0, - A = this.firstCharOffsetByLineMinusOne.length - for (; N < A; ) { - const k = Math.floor((N + A) / 2) - this.firstCharOffsetByLineMinusOne[k] > m ? (A = k) : (N = k + 1) - } - const M = N === 0 ? 0 : this.firstCharOffsetByLineMinusOne[N - 1] - return new s.Position(this.lineRange.start + N + 1, m - M + 1 + this.offsetByLine[N]) - } - translateRange(m) { - return p.Range.fromPositions( - this.translateOffset(m.start), - this.translateOffset(m.endExclusive) - ) - } - findWordContaining(m) { - if (m < 0 || m >= this.elements.length || !C(this.elements[m])) return - let N = m - for (; N > 0 && C(this.elements[N - 1]); ) N-- - let A = m - for (; A < this.elements.length && C(this.elements[A]); ) A++ - return new i.OffsetRange(N, A) - } - } - function C(l) { - return (l >= 97 && l <= 122) || (l >= 65 && l <= 90) || (l >= 48 && l <= 57) - } - const r = { [0]: 0, [1]: 0, [2]: 0, [3]: 10, [4]: 2, [5]: 3, [6]: 10, [7]: 10 } - function u(l) { - return r[l] - } - function o(l) { - return l === 10 - ? 7 - : l === 13 - ? 6 - : c(l) - ? 5 - : l >= 97 && l <= 122 - ? 0 - : l >= 65 && l <= 90 - ? 1 - : l >= 48 && l <= 57 - ? 2 - : l === -1 - ? 3 - : 4 - } - function c(l) { - return l === 32 || l === 9 - } - } - ), - Q(Y[41], X([0, 1, 39, 40]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.linesDiffComputers = void 0), - (n.linesDiffComputers = { - smart: new R.SmartLinesDiffComputer(), - experimental: new D.StandardLinesDiffComputer(), - }) - }), - Q(Y[42], X([0, 1, 20]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.computeLinks = n.LinkComputer = n.StateMachine = void 0) - class D { - constructor(b, f, v) { - const g = new Uint8Array(b * f) - for (let S = 0, E = b * f; S < E; S++) g[S] = v - ;(this._data = g), (this.rows = b), (this.cols = f) - } - get(b, f) { - return this._data[b * this.cols + f] - } - set(b, f, v) { - this._data[b * this.cols + f] = v - } - } - class i { - constructor(b) { - let f = 0, - v = 0 - for (let S = 0, E = b.length; S < E; S++) { - const [y, _, d] = b[S] - _ > f && (f = _), y > v && (v = y), d > v && (v = d) - } - f++, v++ - const g = new D(v, f, 0) - for (let S = 0, E = b.length; S < E; S++) { - const [y, _, d] = b[S] - g.set(y, _, d) - } - ;(this._states = g), (this._maxCharCode = f) - } - nextState(b, f) { - return f < 0 || f >= this._maxCharCode ? 0 : this._states.get(b, f) - } - } - n.StateMachine = i - let s = null - function p() { - return ( - s === null && - (s = new i([ - [1, 104, 2], - [1, 72, 2], - [1, 102, 6], - [1, 70, 6], - [2, 116, 3], - [2, 84, 3], - [3, 116, 4], - [3, 84, 4], - [4, 112, 5], - [4, 80, 5], - [5, 115, 9], - [5, 83, 9], - [5, 58, 10], - [6, 105, 7], - [6, 73, 7], - [7, 108, 8], - [7, 76, 8], - [8, 101, 9], - [8, 69, 9], - [9, 58, 10], - [10, 47, 11], - [11, 47, 12], - ])), - s - ) - } - let L = null - function h() { - if (L === null) { - L = new R.CharacterClassifier(0) - const e = ` <>'"\u3001\u3002\uFF61\uFF64\uFF0C\uFF0E\uFF1A\uFF1B\u2018\u3008\u300C\u300E\u3014\uFF08\uFF3B\uFF5B\uFF62\uFF63\uFF5D\uFF3D\uFF09\u3015\u300F\u300D\u3009\u2019\uFF40\uFF5E\u2026` - for (let f = 0; f < e.length; f++) L.set(e.charCodeAt(f), 1) - const b = '.,;:' - for (let f = 0; f < b.length; f++) L.set(b.charCodeAt(f), 2) - } - return L - } - class a { - static _createLink(b, f, v, g, S) { - let E = S - 1 - do { - const y = f.charCodeAt(E) - if (b.get(y) !== 2) break - E-- - } while (E > g) - if (g > 0) { - const y = f.charCodeAt(g - 1), - _ = f.charCodeAt(E) - ;((y === 40 && _ === 41) || (y === 91 && _ === 93) || (y === 123 && _ === 125)) && E-- - } - return { - range: { startLineNumber: v, startColumn: g + 1, endLineNumber: v, endColumn: E + 2 }, - url: f.substring(g, E + 1), - } - } - static computeLinks(b, f = p()) { - const v = h(), - g = [] - for (let S = 1, E = b.getLineCount(); S <= E; S++) { - const y = b.getLineContent(S), - _ = y.length - let d = 0, - C = 0, - r = 0, - u = 1, - o = !1, - c = !1, - l = !1, - m = !1 - for (; d < _; ) { - let N = !1 - const A = y.charCodeAt(d) - if (u === 13) { - let M - switch (A) { - case 40: - ;(o = !0), (M = 0) - break - case 41: - M = o ? 0 : 1 - break - case 91: - ;(l = !0), (c = !0), (M = 0) - break - case 93: - ;(l = !1), (M = c ? 0 : 1) - break - case 123: - ;(m = !0), (M = 0) - break - case 125: - M = m ? 0 : 1 - break - case 39: - case 34: - case 96: - r === A ? (M = 1) : r === 39 || r === 34 || r === 96 ? (M = 0) : (M = 1) - break - case 42: - M = r === 42 ? 1 : 0 - break - case 124: - M = r === 124 ? 1 : 0 - break - case 32: - M = l ? 0 : 1 - break - default: - M = v.get(A) - } - M === 1 && (g.push(a._createLink(v, y, S, C, d)), (N = !0)) - } else if (u === 12) { - let M - A === 91 ? ((c = !0), (M = 0)) : (M = v.get(A)), M === 1 ? (N = !0) : (u = 13) - } else (u = f.nextState(u, A)), u === 0 && (N = !0) - N && ((u = 1), (o = !1), (c = !1), (m = !1), (C = d + 1), (r = A)), d++ - } - u === 13 && g.push(a._createLink(v, y, S, C, _)) - } - return g - } - } - n.LinkComputer = a - function w(e) { - return !e || typeof e.getLineCount != 'function' || typeof e.getLineContent != 'function' - ? [] - : a.computeLinks(e) - } - n.computeLinks = w - }), - Q(Y[43], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.BasicInplaceReplace = void 0) - class R { - constructor() { - this._defaultValueSet = [ - ['true', 'false'], - ['True', 'False'], - ['Private', 'Public', 'Friend', 'ReadOnly', 'Partial', 'Protected', 'WriteOnly'], - ['public', 'protected', 'private'], - ] - } - navigateValueSet(i, s, p, L, h) { - if (i && s) { - const a = this.doNavigateValueSet(s, h) - if (a) return { range: i, value: a } - } - if (p && L) { - const a = this.doNavigateValueSet(L, h) - if (a) return { range: p, value: a } - } - return null - } - doNavigateValueSet(i, s) { - const p = this.numberReplace(i, s) - return p !== null ? p : this.textReplace(i, s) - } - numberReplace(i, s) { - const p = Math.pow(10, i.length - (i.lastIndexOf('.') + 1)) - let L = Number(i) - const h = parseFloat(i) - return !isNaN(L) && !isNaN(h) && L === h - ? L === 0 && !s - ? null - : ((L = Math.floor(L * p)), (L += s ? p : -p), String(L / p)) - : null - } - textReplace(i, s) { - return this.valueSetsReplace(this._defaultValueSet, i, s) - } - valueSetsReplace(i, s, p) { - let L = null - for (let h = 0, a = i.length; L === null && h < a; h++) - L = this.valueSetReplace(i[h], s, p) - return L - } - valueSetReplace(i, s, p) { - let L = i.indexOf(s) - return L >= 0 - ? ((L += p ? 1 : -1), L < 0 ? (L = i.length - 1) : (L %= i.length), i[L]) - : null - } - } - ;(R.INSTANCE = new R()), (n.BasicInplaceReplace = R) - }), - Q(Y[44], X([0, 1, 12]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.shouldSynchronizeModel = - n.ApplyEditsResult = - n.SearchData = - n.ValidAnnotatedEditOperation = - n.isITextSnapshot = - n.FindMatch = - n.TextModelResolvedOptions = - n.InjectedTextCursorStops = - n.MinimapPosition = - n.OverviewRulerLane = - void 0) - var D - ;(function (f) { - ;(f[(f.Left = 1)] = 'Left'), - (f[(f.Center = 2)] = 'Center'), - (f[(f.Right = 4)] = 'Right'), - (f[(f.Full = 7)] = 'Full') - })((D = n.OverviewRulerLane || (n.OverviewRulerLane = {}))) - var i - ;(function (f) { - ;(f[(f.Inline = 1)] = 'Inline'), (f[(f.Gutter = 2)] = 'Gutter') - })((i = n.MinimapPosition || (n.MinimapPosition = {}))) - var s - ;(function (f) { - ;(f[(f.Both = 0)] = 'Both'), - (f[(f.Right = 1)] = 'Right'), - (f[(f.Left = 2)] = 'Left'), - (f[(f.None = 3)] = 'None') - })((s = n.InjectedTextCursorStops || (n.InjectedTextCursorStops = {}))) - class p { - get originalIndentSize() { - return this._indentSizeIsTabSize ? 'tabSize' : this.indentSize - } - constructor(v) { - ;(this._textModelResolvedOptionsBrand = void 0), - (this.tabSize = Math.max(1, v.tabSize | 0)), - v.indentSize === 'tabSize' - ? ((this.indentSize = this.tabSize), (this._indentSizeIsTabSize = !0)) - : ((this.indentSize = Math.max(1, v.indentSize | 0)), - (this._indentSizeIsTabSize = !1)), - (this.insertSpaces = Boolean(v.insertSpaces)), - (this.defaultEOL = v.defaultEOL | 0), - (this.trimAutoWhitespace = Boolean(v.trimAutoWhitespace)), - (this.bracketPairColorizationOptions = v.bracketPairColorizationOptions) - } - equals(v) { - return ( - this.tabSize === v.tabSize && - this._indentSizeIsTabSize === v._indentSizeIsTabSize && - this.indentSize === v.indentSize && - this.insertSpaces === v.insertSpaces && - this.defaultEOL === v.defaultEOL && - this.trimAutoWhitespace === v.trimAutoWhitespace && - (0, R.equals)(this.bracketPairColorizationOptions, v.bracketPairColorizationOptions) - ) - } - createChangeEvent(v) { - return { - tabSize: this.tabSize !== v.tabSize, - indentSize: this.indentSize !== v.indentSize, - insertSpaces: this.insertSpaces !== v.insertSpaces, - trimAutoWhitespace: this.trimAutoWhitespace !== v.trimAutoWhitespace, - } - } - } - n.TextModelResolvedOptions = p - class L { - constructor(v, g) { - ;(this._findMatchBrand = void 0), (this.range = v), (this.matches = g) - } - } - n.FindMatch = L - function h(f) { - return f && typeof f.read == 'function' - } - n.isITextSnapshot = h - class a { - constructor(v, g, S, E, y, _) { - ;(this.identifier = v), - (this.range = g), - (this.text = S), - (this.forceMoveMarkers = E), - (this.isAutoWhitespaceEdit = y), - (this._isTracked = _) - } - } - n.ValidAnnotatedEditOperation = a - class w { - constructor(v, g, S) { - ;(this.regex = v), (this.wordSeparators = g), (this.simpleSearch = S) - } - } - n.SearchData = w - class e { - constructor(v, g, S) { - ;(this.reverseEdits = v), (this.changes = g), (this.trimAutoWhitespaceLineNumbers = S) - } - } - n.ApplyEditsResult = e - function b(f) { - return !f.isTooLargeForSyncing() && !f.isForSimpleWidget - } - n.shouldSynchronizeModel = b - }), - Q(Y[45], X([0, 1, 26, 19]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.PrefixSumIndexOfResult = n.ConstantTimePrefixSumComputer = n.PrefixSumComputer = void 0) - class i { - constructor(h) { - ;(this.values = h), - (this.prefixSum = new Uint32Array(h.length)), - (this.prefixSumValidIndex = new Int32Array(1)), - (this.prefixSumValidIndex[0] = -1) - } - insertValues(h, a) { - h = (0, D.toUint32)(h) - const w = this.values, - e = this.prefixSum, - b = a.length - return b === 0 - ? !1 - : ((this.values = new Uint32Array(w.length + b)), - this.values.set(w.subarray(0, h), 0), - this.values.set(w.subarray(h), h + b), - this.values.set(a, h), - h - 1 < this.prefixSumValidIndex[0] && (this.prefixSumValidIndex[0] = h - 1), - (this.prefixSum = new Uint32Array(this.values.length)), - this.prefixSumValidIndex[0] >= 0 && - this.prefixSum.set(e.subarray(0, this.prefixSumValidIndex[0] + 1)), - !0) - } - setValue(h, a) { - return ( - (h = (0, D.toUint32)(h)), - (a = (0, D.toUint32)(a)), - this.values[h] === a - ? !1 - : ((this.values[h] = a), - h - 1 < this.prefixSumValidIndex[0] && (this.prefixSumValidIndex[0] = h - 1), - !0) - ) - } - removeValues(h, a) { - ;(h = (0, D.toUint32)(h)), (a = (0, D.toUint32)(a)) - const w = this.values, - e = this.prefixSum - if (h >= w.length) return !1 - const b = w.length - h - return ( - a >= b && (a = b), - a === 0 - ? !1 - : ((this.values = new Uint32Array(w.length - a)), - this.values.set(w.subarray(0, h), 0), - this.values.set(w.subarray(h + a), h), - (this.prefixSum = new Uint32Array(this.values.length)), - h - 1 < this.prefixSumValidIndex[0] && (this.prefixSumValidIndex[0] = h - 1), - this.prefixSumValidIndex[0] >= 0 && - this.prefixSum.set(e.subarray(0, this.prefixSumValidIndex[0] + 1)), - !0) - ) - } - getTotalSum() { - return this.values.length === 0 ? 0 : this._getPrefixSum(this.values.length - 1) - } - getPrefixSum(h) { - return h < 0 ? 0 : ((h = (0, D.toUint32)(h)), this._getPrefixSum(h)) - } - _getPrefixSum(h) { - if (h <= this.prefixSumValidIndex[0]) return this.prefixSum[h] - let a = this.prefixSumValidIndex[0] + 1 - a === 0 && ((this.prefixSum[0] = this.values[0]), a++), - h >= this.values.length && (h = this.values.length - 1) - for (let w = a; w <= h; w++) this.prefixSum[w] = this.prefixSum[w - 1] + this.values[w] - return ( - (this.prefixSumValidIndex[0] = Math.max(this.prefixSumValidIndex[0], h)), - this.prefixSum[h] - ) - } - getIndexOf(h) { - ;(h = Math.floor(h)), this.getTotalSum() - let a = 0, - w = this.values.length - 1, - e = 0, - b = 0, - f = 0 - for (; a <= w; ) - if ( - ((e = (a + (w - a) / 2) | 0), - (b = this.prefixSum[e]), - (f = b - this.values[e]), - h < f) - ) - w = e - 1 - else if (h >= b) a = e + 1 - else break - return new p(e, h - f) - } - } - n.PrefixSumComputer = i - class s { - constructor(h) { - ;(this._values = h), - (this._isValid = !1), - (this._validEndIndex = -1), - (this._prefixSum = []), - (this._indexBySum = []) - } - getTotalSum() { - return this._ensureValid(), this._indexBySum.length - } - getPrefixSum(h) { - return this._ensureValid(), h === 0 ? 0 : this._prefixSum[h - 1] - } - getIndexOf(h) { - this._ensureValid() - const a = this._indexBySum[h], - w = a > 0 ? this._prefixSum[a - 1] : 0 - return new p(a, h - w) - } - removeValues(h, a) { - this._values.splice(h, a), this._invalidate(h) - } - insertValues(h, a) { - ;(this._values = (0, R.arrayInsert)(this._values, h, a)), this._invalidate(h) - } - _invalidate(h) { - ;(this._isValid = !1), (this._validEndIndex = Math.min(this._validEndIndex, h - 1)) - } - _ensureValid() { - if (!this._isValid) { - for (let h = this._validEndIndex + 1, a = this._values.length; h < a; h++) { - const w = this._values[h], - e = h > 0 ? this._prefixSum[h - 1] : 0 - this._prefixSum[h] = e + w - for (let b = 0; b < w; b++) this._indexBySum[e + b] = h - } - ;(this._prefixSum.length = this._values.length), - (this._indexBySum.length = this._prefixSum[this._prefixSum.length - 1]), - (this._isValid = !0), - (this._validEndIndex = this._values.length - 1) - } - } - setValue(h, a) { - this._values[h] !== a && ((this._values[h] = a), this._invalidate(h)) - } - } - n.ConstantTimePrefixSumComputer = s - class p { - constructor(h, a) { - ;(this.index = h), - (this.remainder = a), - (this._prefixSumIndexOfResultBrand = void 0), - (this.index = h), - (this.remainder = a) - } - } - n.PrefixSumIndexOfResult = p - }), - Q(Y[46], X([0, 1, 5, 3, 45]), function (x, n, R, D, i) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.MirrorTextModel = void 0) - class s { - constructor(L, h, a, w) { - ;(this._uri = L), - (this._lines = h), - (this._eol = a), - (this._versionId = w), - (this._lineStarts = null), - (this._cachedTextValue = null) - } - dispose() { - this._lines.length = 0 - } - get version() { - return this._versionId - } - getText() { - return ( - this._cachedTextValue === null && (this._cachedTextValue = this._lines.join(this._eol)), - this._cachedTextValue - ) - } - onEvents(L) { - L.eol && L.eol !== this._eol && ((this._eol = L.eol), (this._lineStarts = null)) - const h = L.changes - for (const a of h) - this._acceptDeleteRange(a.range), - this._acceptInsertText( - new D.Position(a.range.startLineNumber, a.range.startColumn), - a.text - ) - ;(this._versionId = L.versionId), (this._cachedTextValue = null) - } - _ensureLineStarts() { - if (!this._lineStarts) { - const L = this._eol.length, - h = this._lines.length, - a = new Uint32Array(h) - for (let w = 0; w < h; w++) a[w] = this._lines[w].length + L - this._lineStarts = new i.PrefixSumComputer(a) - } - } - _setLineText(L, h) { - ;(this._lines[L] = h), - this._lineStarts && - this._lineStarts.setValue(L, this._lines[L].length + this._eol.length) - } - _acceptDeleteRange(L) { - if (L.startLineNumber === L.endLineNumber) { - if (L.startColumn === L.endColumn) return - this._setLineText( - L.startLineNumber - 1, - this._lines[L.startLineNumber - 1].substring(0, L.startColumn - 1) + - this._lines[L.startLineNumber - 1].substring(L.endColumn - 1) - ) - return - } - this._setLineText( - L.startLineNumber - 1, - this._lines[L.startLineNumber - 1].substring(0, L.startColumn - 1) + - this._lines[L.endLineNumber - 1].substring(L.endColumn - 1) - ), - this._lines.splice(L.startLineNumber, L.endLineNumber - L.startLineNumber), - this._lineStarts && - this._lineStarts.removeValues(L.startLineNumber, L.endLineNumber - L.startLineNumber) - } - _acceptInsertText(L, h) { - if (h.length === 0) return - const a = (0, R.splitLines)(h) - if (a.length === 1) { - this._setLineText( - L.lineNumber - 1, - this._lines[L.lineNumber - 1].substring(0, L.column - 1) + - a[0] + - this._lines[L.lineNumber - 1].substring(L.column - 1) - ) - return - } - ;(a[a.length - 1] += this._lines[L.lineNumber - 1].substring(L.column - 1)), - this._setLineText( - L.lineNumber - 1, - this._lines[L.lineNumber - 1].substring(0, L.column - 1) + a[0] - ) - const w = new Uint32Array(a.length - 1) - for (let e = 1; e < a.length; e++) - this._lines.splice(L.lineNumber + e - 1, 0, a[e]), - (w[e - 1] = a[e].length + this._eol.length) - this._lineStarts && this._lineStarts.insertValues(L.lineNumber, w) - } - } - n.MirrorTextModel = s - }), - Q(Y[47], X([0, 1, 5, 34, 3, 2, 44]), function (x, n, R, D, i, s, p) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.Searcher = - n.isValidMatch = - n.TextModelSearch = - n.createFindMatch = - n.isMultilineRegexSource = - n.SearchParams = - void 0) - const L = 999 - class h { - constructor(y, _, d, C) { - ;(this.searchString = y), - (this.isRegex = _), - (this.matchCase = d), - (this.wordSeparators = C) - } - parseSearchRequest() { - if (this.searchString === '') return null - let y - this.isRegex - ? (y = a(this.searchString)) - : (y = - this.searchString.indexOf(` -`) >= 0) - let _ = null - try { - _ = R.createRegExp(this.searchString, this.isRegex, { - matchCase: this.matchCase, - wholeWord: !1, - multiline: y, - global: !0, - unicode: !0, - }) - } catch { - return null - } - if (!_) return null - let d = !this.isRegex && !y - return ( - d && - this.searchString.toLowerCase() !== this.searchString.toUpperCase() && - (d = this.matchCase), - new p.SearchData( - _, - this.wordSeparators ? (0, D.getMapForWordSeparators)(this.wordSeparators) : null, - d ? this.searchString : null - ) - ) - } - } - n.SearchParams = h - function a(E) { - if (!E || E.length === 0) return !1 - for (let y = 0, _ = E.length; y < _; y++) { - const d = E.charCodeAt(y) - if (d === 10) return !0 - if (d === 92) { - if ((y++, y >= _)) break - const C = E.charCodeAt(y) - if (C === 110 || C === 114 || C === 87) return !0 - } - } - return !1 - } - n.isMultilineRegexSource = a - function w(E, y, _) { - if (!_) return new p.FindMatch(E, null) - const d = [] - for (let C = 0, r = y.length; C < r; C++) d[C] = y[C] - return new p.FindMatch(E, d) - } - n.createFindMatch = w - class e { - constructor(y) { - const _ = [] - let d = 0 - for (let C = 0, r = y.length; C < r; C++) y.charCodeAt(C) === 10 && (_[d++] = C) - this._lineFeedsOffsets = _ - } - findLineFeedCountBeforeOffset(y) { - const _ = this._lineFeedsOffsets - let d = 0, - C = _.length - 1 - if (C === -1 || y <= _[0]) return 0 - for (; d < C; ) { - const r = d + (((C - d) / 2) >> 0) - _[r] >= y ? (C = r - 1) : _[r + 1] >= y ? ((d = r), (C = r)) : (d = r + 1) - } - return d + 1 - } - } - class b { - static findMatches(y, _, d, C, r) { - const u = _.parseSearchRequest() - return u - ? u.regex.multiline - ? this._doFindMatchesMultiline(y, d, new S(u.wordSeparators, u.regex), C, r) - : this._doFindMatchesLineByLine(y, d, u, C, r) - : [] - } - static _getMultilineMatchRange(y, _, d, C, r, u) { - let o, - c = 0 - C ? ((c = C.findLineFeedCountBeforeOffset(r)), (o = _ + r + c)) : (o = _ + r) - let l - if (C) { - const M = C.findLineFeedCountBeforeOffset(r + u.length) - c - l = o + u.length + M - } else l = o + u.length - const m = y.getPositionAt(o), - N = y.getPositionAt(l) - return new s.Range(m.lineNumber, m.column, N.lineNumber, N.column) - } - static _doFindMatchesMultiline(y, _, d, C, r) { - const u = y.getOffsetAt(_.getStartPosition()), - o = y.getValueInRange(_, 1), - c = - y.getEOL() === - `\r -` - ? new e(o) - : null, - l = [] - let m = 0, - N - for (d.reset(0); (N = d.next(o)); ) - if ( - ((l[m++] = w(this._getMultilineMatchRange(y, u, o, c, N.index, N[0]), N, C)), m >= r) - ) - return l - return l - } - static _doFindMatchesLineByLine(y, _, d, C, r) { - const u = [] - let o = 0 - if (_.startLineNumber === _.endLineNumber) { - const l = y - .getLineContent(_.startLineNumber) - .substring(_.startColumn - 1, _.endColumn - 1) - return ( - (o = this._findMatchesInLine(d, l, _.startLineNumber, _.startColumn - 1, o, u, C, r)), - u - ) - } - const c = y.getLineContent(_.startLineNumber).substring(_.startColumn - 1) - o = this._findMatchesInLine(d, c, _.startLineNumber, _.startColumn - 1, o, u, C, r) - for (let l = _.startLineNumber + 1; l < _.endLineNumber && o < r; l++) - o = this._findMatchesInLine(d, y.getLineContent(l), l, 0, o, u, C, r) - if (o < r) { - const l = y.getLineContent(_.endLineNumber).substring(0, _.endColumn - 1) - o = this._findMatchesInLine(d, l, _.endLineNumber, 0, o, u, C, r) - } - return u - } - static _findMatchesInLine(y, _, d, C, r, u, o, c) { - const l = y.wordSeparators - if (!o && y.simpleSearch) { - const A = y.simpleSearch, - M = A.length, - k = _.length - let q = -M - for (; (q = _.indexOf(A, q + M)) !== -1; ) - if ( - (!l || g(l, _, k, q, M)) && - ((u[r++] = new p.FindMatch(new s.Range(d, q + 1 + C, d, q + 1 + M + C), null)), - r >= c) - ) - return r - return r - } - const m = new S(y.wordSeparators, y.regex) - let N - m.reset(0) - do - if ( - ((N = m.next(_)), - N && - ((u[r++] = w( - new s.Range(d, N.index + 1 + C, d, N.index + 1 + N[0].length + C), - N, - o - )), - r >= c)) - ) - return r - while (N) - return r - } - static findNextMatch(y, _, d, C) { - const r = _.parseSearchRequest() - if (!r) return null - const u = new S(r.wordSeparators, r.regex) - return r.regex.multiline - ? this._doFindNextMatchMultiline(y, d, u, C) - : this._doFindNextMatchLineByLine(y, d, u, C) - } - static _doFindNextMatchMultiline(y, _, d, C) { - const r = new i.Position(_.lineNumber, 1), - u = y.getOffsetAt(r), - o = y.getLineCount(), - c = y.getValueInRange(new s.Range(r.lineNumber, r.column, o, y.getLineMaxColumn(o)), 1), - l = - y.getEOL() === - `\r -` - ? new e(c) - : null - d.reset(_.column - 1) - const m = d.next(c) - return m - ? w(this._getMultilineMatchRange(y, u, c, l, m.index, m[0]), m, C) - : _.lineNumber !== 1 || _.column !== 1 - ? this._doFindNextMatchMultiline(y, new i.Position(1, 1), d, C) - : null - } - static _doFindNextMatchLineByLine(y, _, d, C) { - const r = y.getLineCount(), - u = _.lineNumber, - o = y.getLineContent(u), - c = this._findFirstMatchInLine(d, o, u, _.column, C) - if (c) return c - for (let l = 1; l <= r; l++) { - const m = (u + l - 1) % r, - N = y.getLineContent(m + 1), - A = this._findFirstMatchInLine(d, N, m + 1, 1, C) - if (A) return A - } - return null - } - static _findFirstMatchInLine(y, _, d, C, r) { - y.reset(C - 1) - const u = y.next(_) - return u ? w(new s.Range(d, u.index + 1, d, u.index + 1 + u[0].length), u, r) : null - } - static findPreviousMatch(y, _, d, C) { - const r = _.parseSearchRequest() - if (!r) return null - const u = new S(r.wordSeparators, r.regex) - return r.regex.multiline - ? this._doFindPreviousMatchMultiline(y, d, u, C) - : this._doFindPreviousMatchLineByLine(y, d, u, C) - } - static _doFindPreviousMatchMultiline(y, _, d, C) { - const r = this._doFindMatchesMultiline( - y, - new s.Range(1, 1, _.lineNumber, _.column), - d, - C, - 10 * L - ) - if (r.length > 0) return r[r.length - 1] - const u = y.getLineCount() - return _.lineNumber !== u || _.column !== y.getLineMaxColumn(u) - ? this._doFindPreviousMatchMultiline(y, new i.Position(u, y.getLineMaxColumn(u)), d, C) - : null - } - static _doFindPreviousMatchLineByLine(y, _, d, C) { - const r = y.getLineCount(), - u = _.lineNumber, - o = y.getLineContent(u).substring(0, _.column - 1), - c = this._findLastMatchInLine(d, o, u, C) - if (c) return c - for (let l = 1; l <= r; l++) { - const m = (r + u - l - 1) % r, - N = y.getLineContent(m + 1), - A = this._findLastMatchInLine(d, N, m + 1, C) - if (A) return A - } - return null - } - static _findLastMatchInLine(y, _, d, C) { - let r = null, - u - for (y.reset(0); (u = y.next(_)); ) - r = w(new s.Range(d, u.index + 1, d, u.index + 1 + u[0].length), u, C) - return r - } - } - n.TextModelSearch = b - function f(E, y, _, d, C) { - if (d === 0) return !0 - const r = y.charCodeAt(d - 1) - if (E.get(r) !== 0 || r === 13 || r === 10) return !0 - if (C > 0) { - const u = y.charCodeAt(d) - if (E.get(u) !== 0) return !0 - } - return !1 - } - function v(E, y, _, d, C) { - if (d + C === _) return !0 - const r = y.charCodeAt(d + C) - if (E.get(r) !== 0 || r === 13 || r === 10) return !0 - if (C > 0) { - const u = y.charCodeAt(d + C - 1) - if (E.get(u) !== 0) return !0 - } - return !1 - } - function g(E, y, _, d, C) { - return f(E, y, _, d, C) && v(E, y, _, d, C) - } - n.isValidMatch = g - class S { - constructor(y, _) { - ;(this._wordSeparators = y), - (this._searchRegex = _), - (this._prevMatchStartIndex = -1), - (this._prevMatchLength = 0) - } - reset(y) { - ;(this._searchRegex.lastIndex = y), - (this._prevMatchStartIndex = -1), - (this._prevMatchLength = 0) - } - next(y) { - const _ = y.length - let d - do { - if ( - this._prevMatchStartIndex + this._prevMatchLength === _ || - ((d = this._searchRegex.exec(y)), !d) - ) - return null - const C = d.index, - r = d[0].length - if (C === this._prevMatchStartIndex && r === this._prevMatchLength) { - if (r === 0) { - R.getNextCodePoint(y, _, this._searchRegex.lastIndex) > 65535 - ? (this._searchRegex.lastIndex += 2) - : (this._searchRegex.lastIndex += 1) - continue - } - return null - } - if ( - ((this._prevMatchStartIndex = C), - (this._prevMatchLength = r), - !this._wordSeparators || g(this._wordSeparators, y, _, C, r)) - ) - return d - } while (d) - return null - } - } - n.Searcher = S - }), - Q(Y[48], X([0, 1, 2, 47, 5, 10, 22]), function (x, n, R, D, i, s, p) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.UnicodeTextModelHighlighter = void 0) - class L { - static computeUnicodeHighlights(b, f, v) { - const g = v ? v.startLineNumber : 1, - S = v ? v.endLineNumber : b.getLineCount(), - E = new a(f), - y = E.getCandidateCodePoints() - let _ - y === 'allNonBasicAscii' - ? (_ = new RegExp('[^\\t\\n\\r\\x20-\\x7E]', 'g')) - : (_ = new RegExp(`${h(Array.from(y))}`, 'g')) - const d = new D.Searcher(null, _), - C = [] - let r = !1, - u, - o = 0, - c = 0, - l = 0 - e: for (let m = g, N = S; m <= N; m++) { - const A = b.getLineContent(m), - M = A.length - d.reset(0) - do - if (((u = d.next(A)), u)) { - let k = u.index, - q = u.index + u[0].length - if (k > 0) { - const F = A.charCodeAt(k - 1) - i.isHighSurrogate(F) && k-- - } - if (q + 1 < M) { - const F = A.charCodeAt(q - 1) - i.isHighSurrogate(F) && q++ - } - const I = A.substring(k, q) - let B = (0, p.getWordAtText)(k + 1, p.DEFAULT_WORD_REGEXP, A, 0) - B && B.endColumn <= k + 1 && (B = null) - const H = E.shouldHighlightNonBasicASCII(I, B ? B.word : null) - if (H !== 0) { - H === 3 ? o++ : H === 2 ? c++ : H === 1 ? l++ : (0, s.assertNever)(H) - const F = 1e3 - if (C.length >= F) { - r = !0 - break e - } - C.push(new R.Range(m, k + 1, m, q + 1)) - } - } - while (u) - } - return { - ranges: C, - hasMore: r, - ambiguousCharacterCount: o, - invisibleCharacterCount: c, - nonBasicAsciiCharacterCount: l, - } - } - static computeUnicodeHighlightReason(b, f) { - const v = new a(f) - switch (v.shouldHighlightNonBasicASCII(b, null)) { - case 0: - return null - case 2: - return { kind: 1 } - case 3: { - const S = b.codePointAt(0), - E = v.ambiguousCharacters.getPrimaryConfusable(S), - y = i.AmbiguousCharacters.getLocales().filter( - (_) => - !i.AmbiguousCharacters.getInstance( - new Set([...f.allowedLocales, _]) - ).isAmbiguous(S) - ) - return { kind: 0, confusableWith: String.fromCodePoint(E), notAmbiguousInLocales: y } - } - case 1: - return { kind: 2 } - } - } - } - n.UnicodeTextModelHighlighter = L - function h(e, b) { - return `[${i.escapeRegExpCharacters(e.map((v) => String.fromCodePoint(v)).join(''))}]` - } - class a { - constructor(b) { - ;(this.options = b), - (this.allowedCodePoints = new Set(b.allowedCodePoints)), - (this.ambiguousCharacters = i.AmbiguousCharacters.getInstance( - new Set(b.allowedLocales) - )) - } - getCandidateCodePoints() { - if (this.options.nonBasicASCII) return 'allNonBasicAscii' - const b = new Set() - if (this.options.invisibleCharacters) - for (const f of i.InvisibleCharacters.codePoints) w(String.fromCodePoint(f)) || b.add(f) - if (this.options.ambiguousCharacters) - for (const f of this.ambiguousCharacters.getConfusableCodePoints()) b.add(f) - for (const f of this.allowedCodePoints) b.delete(f) - return b - } - shouldHighlightNonBasicASCII(b, f) { - const v = b.codePointAt(0) - if (this.allowedCodePoints.has(v)) return 0 - if (this.options.nonBasicASCII) return 1 - let g = !1, - S = !1 - if (f) - for (const E of f) { - const y = E.codePointAt(0), - _ = i.isBasicASCII(E) - ;(g = g || _), - !_ && - !this.ambiguousCharacters.isAmbiguous(y) && - !i.InvisibleCharacters.isInvisibleCharacter(y) && - (S = !0) - } - return !g && S - ? 0 - : this.options.invisibleCharacters && - !w(b) && - i.InvisibleCharacters.isInvisibleCharacter(v) - ? 2 - : this.options.ambiguousCharacters && this.ambiguousCharacters.isAmbiguous(v) - ? 3 - : 0 - } - } - function w(e) { - return ( - e === ' ' || - e === - ` -` || - e === ' ' - ) - } - }), - Q(Y[49], X([0, 1]), function (x, n) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.WrappingIndent = - n.TrackedRangeStickiness = - n.TextEditorCursorStyle = - n.TextEditorCursorBlinkingStyle = - n.SymbolTag = - n.SymbolKind = - n.SignatureHelpTriggerKind = - n.SelectionDirection = - n.ScrollbarVisibility = - n.ScrollType = - n.RenderMinimap = - n.RenderLineNumbersType = - n.PositionAffinity = - n.OverviewRulerLane = - n.OverlayWidgetPositionPreference = - n.MouseTargetType = - n.MinimapPosition = - n.MarkerTag = - n.MarkerSeverity = - n.KeyCode = - n.InlineCompletionTriggerKind = - n.InlayHintKind = - n.InjectedTextCursorStops = - n.IndentAction = - n.EndOfLineSequence = - n.EndOfLinePreference = - n.EditorOption = - n.EditorAutoIndentStrategy = - n.DocumentHighlightKind = - n.DefaultEndOfLine = - n.CursorChangeReason = - n.ContentWidgetPositionPreference = - n.CompletionTriggerKind = - n.CompletionItemTag = - n.CompletionItemKind = - n.CompletionItemInsertTextRule = - n.CodeActionTriggerType = - n.AccessibilitySupport = - void 0) - var R - ;(function (t) { - ;(t[(t.Unknown = 0)] = 'Unknown'), - (t[(t.Disabled = 1)] = 'Disabled'), - (t[(t.Enabled = 2)] = 'Enabled') - })((R = n.AccessibilitySupport || (n.AccessibilitySupport = {}))) - var D - ;(function (t) { - ;(t[(t.Invoke = 1)] = 'Invoke'), (t[(t.Auto = 2)] = 'Auto') - })((D = n.CodeActionTriggerType || (n.CodeActionTriggerType = {}))) - var i - ;(function (t) { - ;(t[(t.None = 0)] = 'None'), - (t[(t.KeepWhitespace = 1)] = 'KeepWhitespace'), - (t[(t.InsertAsSnippet = 4)] = 'InsertAsSnippet') - })((i = n.CompletionItemInsertTextRule || (n.CompletionItemInsertTextRule = {}))) - var s - ;(function (t) { - ;(t[(t.Method = 0)] = 'Method'), - (t[(t.Function = 1)] = 'Function'), - (t[(t.Constructor = 2)] = 'Constructor'), - (t[(t.Field = 3)] = 'Field'), - (t[(t.Variable = 4)] = 'Variable'), - (t[(t.Class = 5)] = 'Class'), - (t[(t.Struct = 6)] = 'Struct'), - (t[(t.Interface = 7)] = 'Interface'), - (t[(t.Module = 8)] = 'Module'), - (t[(t.Property = 9)] = 'Property'), - (t[(t.Event = 10)] = 'Event'), - (t[(t.Operator = 11)] = 'Operator'), - (t[(t.Unit = 12)] = 'Unit'), - (t[(t.Value = 13)] = 'Value'), - (t[(t.Constant = 14)] = 'Constant'), - (t[(t.Enum = 15)] = 'Enum'), - (t[(t.EnumMember = 16)] = 'EnumMember'), - (t[(t.Keyword = 17)] = 'Keyword'), - (t[(t.Text = 18)] = 'Text'), - (t[(t.Color = 19)] = 'Color'), - (t[(t.File = 20)] = 'File'), - (t[(t.Reference = 21)] = 'Reference'), - (t[(t.Customcolor = 22)] = 'Customcolor'), - (t[(t.Folder = 23)] = 'Folder'), - (t[(t.TypeParameter = 24)] = 'TypeParameter'), - (t[(t.User = 25)] = 'User'), - (t[(t.Issue = 26)] = 'Issue'), - (t[(t.Snippet = 27)] = 'Snippet') - })((s = n.CompletionItemKind || (n.CompletionItemKind = {}))) - var p - ;(function (t) { - t[(t.Deprecated = 1)] = 'Deprecated' - })((p = n.CompletionItemTag || (n.CompletionItemTag = {}))) - var L - ;(function (t) { - ;(t[(t.Invoke = 0)] = 'Invoke'), - (t[(t.TriggerCharacter = 1)] = 'TriggerCharacter'), - (t[(t.TriggerForIncompleteCompletions = 2)] = 'TriggerForIncompleteCompletions') - })((L = n.CompletionTriggerKind || (n.CompletionTriggerKind = {}))) - var h - ;(function (t) { - ;(t[(t.EXACT = 0)] = 'EXACT'), (t[(t.ABOVE = 1)] = 'ABOVE'), (t[(t.BELOW = 2)] = 'BELOW') - })((h = n.ContentWidgetPositionPreference || (n.ContentWidgetPositionPreference = {}))) - var a - ;(function (t) { - ;(t[(t.NotSet = 0)] = 'NotSet'), - (t[(t.ContentFlush = 1)] = 'ContentFlush'), - (t[(t.RecoverFromMarkers = 2)] = 'RecoverFromMarkers'), - (t[(t.Explicit = 3)] = 'Explicit'), - (t[(t.Paste = 4)] = 'Paste'), - (t[(t.Undo = 5)] = 'Undo'), - (t[(t.Redo = 6)] = 'Redo') - })((a = n.CursorChangeReason || (n.CursorChangeReason = {}))) - var w - ;(function (t) { - ;(t[(t.LF = 1)] = 'LF'), (t[(t.CRLF = 2)] = 'CRLF') - })((w = n.DefaultEndOfLine || (n.DefaultEndOfLine = {}))) - var e - ;(function (t) { - ;(t[(t.Text = 0)] = 'Text'), (t[(t.Read = 1)] = 'Read'), (t[(t.Write = 2)] = 'Write') - })((e = n.DocumentHighlightKind || (n.DocumentHighlightKind = {}))) - var b - ;(function (t) { - ;(t[(t.None = 0)] = 'None'), - (t[(t.Keep = 1)] = 'Keep'), - (t[(t.Brackets = 2)] = 'Brackets'), - (t[(t.Advanced = 3)] = 'Advanced'), - (t[(t.Full = 4)] = 'Full') - })((b = n.EditorAutoIndentStrategy || (n.EditorAutoIndentStrategy = {}))) - var f - ;(function (t) { - ;(t[(t.acceptSuggestionOnCommitCharacter = 0)] = 'acceptSuggestionOnCommitCharacter'), - (t[(t.acceptSuggestionOnEnter = 1)] = 'acceptSuggestionOnEnter'), - (t[(t.accessibilitySupport = 2)] = 'accessibilitySupport'), - (t[(t.accessibilityPageSize = 3)] = 'accessibilityPageSize'), - (t[(t.ariaLabel = 4)] = 'ariaLabel'), - (t[(t.autoClosingBrackets = 5)] = 'autoClosingBrackets'), - (t[(t.screenReaderAnnounceInlineSuggestion = 6)] = - 'screenReaderAnnounceInlineSuggestion'), - (t[(t.autoClosingDelete = 7)] = 'autoClosingDelete'), - (t[(t.autoClosingOvertype = 8)] = 'autoClosingOvertype'), - (t[(t.autoClosingQuotes = 9)] = 'autoClosingQuotes'), - (t[(t.autoIndent = 10)] = 'autoIndent'), - (t[(t.automaticLayout = 11)] = 'automaticLayout'), - (t[(t.autoSurround = 12)] = 'autoSurround'), - (t[(t.bracketPairColorization = 13)] = 'bracketPairColorization'), - (t[(t.guides = 14)] = 'guides'), - (t[(t.codeLens = 15)] = 'codeLens'), - (t[(t.codeLensFontFamily = 16)] = 'codeLensFontFamily'), - (t[(t.codeLensFontSize = 17)] = 'codeLensFontSize'), - (t[(t.colorDecorators = 18)] = 'colorDecorators'), - (t[(t.colorDecoratorsLimit = 19)] = 'colorDecoratorsLimit'), - (t[(t.columnSelection = 20)] = 'columnSelection'), - (t[(t.comments = 21)] = 'comments'), - (t[(t.contextmenu = 22)] = 'contextmenu'), - (t[(t.copyWithSyntaxHighlighting = 23)] = 'copyWithSyntaxHighlighting'), - (t[(t.cursorBlinking = 24)] = 'cursorBlinking'), - (t[(t.cursorSmoothCaretAnimation = 25)] = 'cursorSmoothCaretAnimation'), - (t[(t.cursorStyle = 26)] = 'cursorStyle'), - (t[(t.cursorSurroundingLines = 27)] = 'cursorSurroundingLines'), - (t[(t.cursorSurroundingLinesStyle = 28)] = 'cursorSurroundingLinesStyle'), - (t[(t.cursorWidth = 29)] = 'cursorWidth'), - (t[(t.disableLayerHinting = 30)] = 'disableLayerHinting'), - (t[(t.disableMonospaceOptimizations = 31)] = 'disableMonospaceOptimizations'), - (t[(t.domReadOnly = 32)] = 'domReadOnly'), - (t[(t.dragAndDrop = 33)] = 'dragAndDrop'), - (t[(t.dropIntoEditor = 34)] = 'dropIntoEditor'), - (t[(t.emptySelectionClipboard = 35)] = 'emptySelectionClipboard'), - (t[(t.experimentalWhitespaceRendering = 36)] = 'experimentalWhitespaceRendering'), - (t[(t.extraEditorClassName = 37)] = 'extraEditorClassName'), - (t[(t.fastScrollSensitivity = 38)] = 'fastScrollSensitivity'), - (t[(t.find = 39)] = 'find'), - (t[(t.fixedOverflowWidgets = 40)] = 'fixedOverflowWidgets'), - (t[(t.folding = 41)] = 'folding'), - (t[(t.foldingStrategy = 42)] = 'foldingStrategy'), - (t[(t.foldingHighlight = 43)] = 'foldingHighlight'), - (t[(t.foldingImportsByDefault = 44)] = 'foldingImportsByDefault'), - (t[(t.foldingMaximumRegions = 45)] = 'foldingMaximumRegions'), - (t[(t.unfoldOnClickAfterEndOfLine = 46)] = 'unfoldOnClickAfterEndOfLine'), - (t[(t.fontFamily = 47)] = 'fontFamily'), - (t[(t.fontInfo = 48)] = 'fontInfo'), - (t[(t.fontLigatures = 49)] = 'fontLigatures'), - (t[(t.fontSize = 50)] = 'fontSize'), - (t[(t.fontWeight = 51)] = 'fontWeight'), - (t[(t.fontVariations = 52)] = 'fontVariations'), - (t[(t.formatOnPaste = 53)] = 'formatOnPaste'), - (t[(t.formatOnType = 54)] = 'formatOnType'), - (t[(t.glyphMargin = 55)] = 'glyphMargin'), - (t[(t.gotoLocation = 56)] = 'gotoLocation'), - (t[(t.hideCursorInOverviewRuler = 57)] = 'hideCursorInOverviewRuler'), - (t[(t.hover = 58)] = 'hover'), - (t[(t.inDiffEditor = 59)] = 'inDiffEditor'), - (t[(t.inlineSuggest = 60)] = 'inlineSuggest'), - (t[(t.letterSpacing = 61)] = 'letterSpacing'), - (t[(t.lightbulb = 62)] = 'lightbulb'), - (t[(t.lineDecorationsWidth = 63)] = 'lineDecorationsWidth'), - (t[(t.lineHeight = 64)] = 'lineHeight'), - (t[(t.lineNumbers = 65)] = 'lineNumbers'), - (t[(t.lineNumbersMinChars = 66)] = 'lineNumbersMinChars'), - (t[(t.linkedEditing = 67)] = 'linkedEditing'), - (t[(t.links = 68)] = 'links'), - (t[(t.matchBrackets = 69)] = 'matchBrackets'), - (t[(t.minimap = 70)] = 'minimap'), - (t[(t.mouseStyle = 71)] = 'mouseStyle'), - (t[(t.mouseWheelScrollSensitivity = 72)] = 'mouseWheelScrollSensitivity'), - (t[(t.mouseWheelZoom = 73)] = 'mouseWheelZoom'), - (t[(t.multiCursorMergeOverlapping = 74)] = 'multiCursorMergeOverlapping'), - (t[(t.multiCursorModifier = 75)] = 'multiCursorModifier'), - (t[(t.multiCursorPaste = 76)] = 'multiCursorPaste'), - (t[(t.multiCursorLimit = 77)] = 'multiCursorLimit'), - (t[(t.occurrencesHighlight = 78)] = 'occurrencesHighlight'), - (t[(t.overviewRulerBorder = 79)] = 'overviewRulerBorder'), - (t[(t.overviewRulerLanes = 80)] = 'overviewRulerLanes'), - (t[(t.padding = 81)] = 'padding'), - (t[(t.parameterHints = 82)] = 'parameterHints'), - (t[(t.peekWidgetDefaultFocus = 83)] = 'peekWidgetDefaultFocus'), - (t[(t.definitionLinkOpensInPeek = 84)] = 'definitionLinkOpensInPeek'), - (t[(t.quickSuggestions = 85)] = 'quickSuggestions'), - (t[(t.quickSuggestionsDelay = 86)] = 'quickSuggestionsDelay'), - (t[(t.readOnly = 87)] = 'readOnly'), - (t[(t.renameOnType = 88)] = 'renameOnType'), - (t[(t.renderControlCharacters = 89)] = 'renderControlCharacters'), - (t[(t.renderFinalNewline = 90)] = 'renderFinalNewline'), - (t[(t.renderLineHighlight = 91)] = 'renderLineHighlight'), - (t[(t.renderLineHighlightOnlyWhenFocus = 92)] = 'renderLineHighlightOnlyWhenFocus'), - (t[(t.renderValidationDecorations = 93)] = 'renderValidationDecorations'), - (t[(t.renderWhitespace = 94)] = 'renderWhitespace'), - (t[(t.revealHorizontalRightPadding = 95)] = 'revealHorizontalRightPadding'), - (t[(t.roundedSelection = 96)] = 'roundedSelection'), - (t[(t.rulers = 97)] = 'rulers'), - (t[(t.scrollbar = 98)] = 'scrollbar'), - (t[(t.scrollBeyondLastColumn = 99)] = 'scrollBeyondLastColumn'), - (t[(t.scrollBeyondLastLine = 100)] = 'scrollBeyondLastLine'), - (t[(t.scrollPredominantAxis = 101)] = 'scrollPredominantAxis'), - (t[(t.selectionClipboard = 102)] = 'selectionClipboard'), - (t[(t.selectionHighlight = 103)] = 'selectionHighlight'), - (t[(t.selectOnLineNumbers = 104)] = 'selectOnLineNumbers'), - (t[(t.showFoldingControls = 105)] = 'showFoldingControls'), - (t[(t.showUnused = 106)] = 'showUnused'), - (t[(t.snippetSuggestions = 107)] = 'snippetSuggestions'), - (t[(t.smartSelect = 108)] = 'smartSelect'), - (t[(t.smoothScrolling = 109)] = 'smoothScrolling'), - (t[(t.stickyScroll = 110)] = 'stickyScroll'), - (t[(t.stickyTabStops = 111)] = 'stickyTabStops'), - (t[(t.stopRenderingLineAfter = 112)] = 'stopRenderingLineAfter'), - (t[(t.suggest = 113)] = 'suggest'), - (t[(t.suggestFontSize = 114)] = 'suggestFontSize'), - (t[(t.suggestLineHeight = 115)] = 'suggestLineHeight'), - (t[(t.suggestOnTriggerCharacters = 116)] = 'suggestOnTriggerCharacters'), - (t[(t.suggestSelection = 117)] = 'suggestSelection'), - (t[(t.tabCompletion = 118)] = 'tabCompletion'), - (t[(t.tabIndex = 119)] = 'tabIndex'), - (t[(t.unicodeHighlighting = 120)] = 'unicodeHighlighting'), - (t[(t.unusualLineTerminators = 121)] = 'unusualLineTerminators'), - (t[(t.useShadowDOM = 122)] = 'useShadowDOM'), - (t[(t.useTabStops = 123)] = 'useTabStops'), - (t[(t.wordBreak = 124)] = 'wordBreak'), - (t[(t.wordSeparators = 125)] = 'wordSeparators'), - (t[(t.wordWrap = 126)] = 'wordWrap'), - (t[(t.wordWrapBreakAfterCharacters = 127)] = 'wordWrapBreakAfterCharacters'), - (t[(t.wordWrapBreakBeforeCharacters = 128)] = 'wordWrapBreakBeforeCharacters'), - (t[(t.wordWrapColumn = 129)] = 'wordWrapColumn'), - (t[(t.wordWrapOverride1 = 130)] = 'wordWrapOverride1'), - (t[(t.wordWrapOverride2 = 131)] = 'wordWrapOverride2'), - (t[(t.wrappingIndent = 132)] = 'wrappingIndent'), - (t[(t.wrappingStrategy = 133)] = 'wrappingStrategy'), - (t[(t.showDeprecated = 134)] = 'showDeprecated'), - (t[(t.inlayHints = 135)] = 'inlayHints'), - (t[(t.editorClassName = 136)] = 'editorClassName'), - (t[(t.pixelRatio = 137)] = 'pixelRatio'), - (t[(t.tabFocusMode = 138)] = 'tabFocusMode'), - (t[(t.layoutInfo = 139)] = 'layoutInfo'), - (t[(t.wrappingInfo = 140)] = 'wrappingInfo') - })((f = n.EditorOption || (n.EditorOption = {}))) - var v - ;(function (t) { - ;(t[(t.TextDefined = 0)] = 'TextDefined'), - (t[(t.LF = 1)] = 'LF'), - (t[(t.CRLF = 2)] = 'CRLF') - })((v = n.EndOfLinePreference || (n.EndOfLinePreference = {}))) - var g - ;(function (t) { - ;(t[(t.LF = 0)] = 'LF'), (t[(t.CRLF = 1)] = 'CRLF') - })((g = n.EndOfLineSequence || (n.EndOfLineSequence = {}))) - var S - ;(function (t) { - ;(t[(t.None = 0)] = 'None'), - (t[(t.Indent = 1)] = 'Indent'), - (t[(t.IndentOutdent = 2)] = 'IndentOutdent'), - (t[(t.Outdent = 3)] = 'Outdent') - })((S = n.IndentAction || (n.IndentAction = {}))) - var E - ;(function (t) { - ;(t[(t.Both = 0)] = 'Both'), - (t[(t.Right = 1)] = 'Right'), - (t[(t.Left = 2)] = 'Left'), - (t[(t.None = 3)] = 'None') - })((E = n.InjectedTextCursorStops || (n.InjectedTextCursorStops = {}))) - var y - ;(function (t) { - ;(t[(t.Type = 1)] = 'Type'), (t[(t.Parameter = 2)] = 'Parameter') - })((y = n.InlayHintKind || (n.InlayHintKind = {}))) - var _ - ;(function (t) { - ;(t[(t.Automatic = 0)] = 'Automatic'), (t[(t.Explicit = 1)] = 'Explicit') - })((_ = n.InlineCompletionTriggerKind || (n.InlineCompletionTriggerKind = {}))) - var d - ;(function (t) { - ;(t[(t.DependsOnKbLayout = -1)] = 'DependsOnKbLayout'), - (t[(t.Unknown = 0)] = 'Unknown'), - (t[(t.Backspace = 1)] = 'Backspace'), - (t[(t.Tab = 2)] = 'Tab'), - (t[(t.Enter = 3)] = 'Enter'), - (t[(t.Shift = 4)] = 'Shift'), - (t[(t.Ctrl = 5)] = 'Ctrl'), - (t[(t.Alt = 6)] = 'Alt'), - (t[(t.PauseBreak = 7)] = 'PauseBreak'), - (t[(t.CapsLock = 8)] = 'CapsLock'), - (t[(t.Escape = 9)] = 'Escape'), - (t[(t.Space = 10)] = 'Space'), - (t[(t.PageUp = 11)] = 'PageUp'), - (t[(t.PageDown = 12)] = 'PageDown'), - (t[(t.End = 13)] = 'End'), - (t[(t.Home = 14)] = 'Home'), - (t[(t.LeftArrow = 15)] = 'LeftArrow'), - (t[(t.UpArrow = 16)] = 'UpArrow'), - (t[(t.RightArrow = 17)] = 'RightArrow'), - (t[(t.DownArrow = 18)] = 'DownArrow'), - (t[(t.Insert = 19)] = 'Insert'), - (t[(t.Delete = 20)] = 'Delete'), - (t[(t.Digit0 = 21)] = 'Digit0'), - (t[(t.Digit1 = 22)] = 'Digit1'), - (t[(t.Digit2 = 23)] = 'Digit2'), - (t[(t.Digit3 = 24)] = 'Digit3'), - (t[(t.Digit4 = 25)] = 'Digit4'), - (t[(t.Digit5 = 26)] = 'Digit5'), - (t[(t.Digit6 = 27)] = 'Digit6'), - (t[(t.Digit7 = 28)] = 'Digit7'), - (t[(t.Digit8 = 29)] = 'Digit8'), - (t[(t.Digit9 = 30)] = 'Digit9'), - (t[(t.KeyA = 31)] = 'KeyA'), - (t[(t.KeyB = 32)] = 'KeyB'), - (t[(t.KeyC = 33)] = 'KeyC'), - (t[(t.KeyD = 34)] = 'KeyD'), - (t[(t.KeyE = 35)] = 'KeyE'), - (t[(t.KeyF = 36)] = 'KeyF'), - (t[(t.KeyG = 37)] = 'KeyG'), - (t[(t.KeyH = 38)] = 'KeyH'), - (t[(t.KeyI = 39)] = 'KeyI'), - (t[(t.KeyJ = 40)] = 'KeyJ'), - (t[(t.KeyK = 41)] = 'KeyK'), - (t[(t.KeyL = 42)] = 'KeyL'), - (t[(t.KeyM = 43)] = 'KeyM'), - (t[(t.KeyN = 44)] = 'KeyN'), - (t[(t.KeyO = 45)] = 'KeyO'), - (t[(t.KeyP = 46)] = 'KeyP'), - (t[(t.KeyQ = 47)] = 'KeyQ'), - (t[(t.KeyR = 48)] = 'KeyR'), - (t[(t.KeyS = 49)] = 'KeyS'), - (t[(t.KeyT = 50)] = 'KeyT'), - (t[(t.KeyU = 51)] = 'KeyU'), - (t[(t.KeyV = 52)] = 'KeyV'), - (t[(t.KeyW = 53)] = 'KeyW'), - (t[(t.KeyX = 54)] = 'KeyX'), - (t[(t.KeyY = 55)] = 'KeyY'), - (t[(t.KeyZ = 56)] = 'KeyZ'), - (t[(t.Meta = 57)] = 'Meta'), - (t[(t.ContextMenu = 58)] = 'ContextMenu'), - (t[(t.F1 = 59)] = 'F1'), - (t[(t.F2 = 60)] = 'F2'), - (t[(t.F3 = 61)] = 'F3'), - (t[(t.F4 = 62)] = 'F4'), - (t[(t.F5 = 63)] = 'F5'), - (t[(t.F6 = 64)] = 'F6'), - (t[(t.F7 = 65)] = 'F7'), - (t[(t.F8 = 66)] = 'F8'), - (t[(t.F9 = 67)] = 'F9'), - (t[(t.F10 = 68)] = 'F10'), - (t[(t.F11 = 69)] = 'F11'), - (t[(t.F12 = 70)] = 'F12'), - (t[(t.F13 = 71)] = 'F13'), - (t[(t.F14 = 72)] = 'F14'), - (t[(t.F15 = 73)] = 'F15'), - (t[(t.F16 = 74)] = 'F16'), - (t[(t.F17 = 75)] = 'F17'), - (t[(t.F18 = 76)] = 'F18'), - (t[(t.F19 = 77)] = 'F19'), - (t[(t.NumLock = 78)] = 'NumLock'), - (t[(t.ScrollLock = 79)] = 'ScrollLock'), - (t[(t.Semicolon = 80)] = 'Semicolon'), - (t[(t.Equal = 81)] = 'Equal'), - (t[(t.Comma = 82)] = 'Comma'), - (t[(t.Minus = 83)] = 'Minus'), - (t[(t.Period = 84)] = 'Period'), - (t[(t.Slash = 85)] = 'Slash'), - (t[(t.Backquote = 86)] = 'Backquote'), - (t[(t.BracketLeft = 87)] = 'BracketLeft'), - (t[(t.Backslash = 88)] = 'Backslash'), - (t[(t.BracketRight = 89)] = 'BracketRight'), - (t[(t.Quote = 90)] = 'Quote'), - (t[(t.OEM_8 = 91)] = 'OEM_8'), - (t[(t.IntlBackslash = 92)] = 'IntlBackslash'), - (t[(t.Numpad0 = 93)] = 'Numpad0'), - (t[(t.Numpad1 = 94)] = 'Numpad1'), - (t[(t.Numpad2 = 95)] = 'Numpad2'), - (t[(t.Numpad3 = 96)] = 'Numpad3'), - (t[(t.Numpad4 = 97)] = 'Numpad4'), - (t[(t.Numpad5 = 98)] = 'Numpad5'), - (t[(t.Numpad6 = 99)] = 'Numpad6'), - (t[(t.Numpad7 = 100)] = 'Numpad7'), - (t[(t.Numpad8 = 101)] = 'Numpad8'), - (t[(t.Numpad9 = 102)] = 'Numpad9'), - (t[(t.NumpadMultiply = 103)] = 'NumpadMultiply'), - (t[(t.NumpadAdd = 104)] = 'NumpadAdd'), - (t[(t.NUMPAD_SEPARATOR = 105)] = 'NUMPAD_SEPARATOR'), - (t[(t.NumpadSubtract = 106)] = 'NumpadSubtract'), - (t[(t.NumpadDecimal = 107)] = 'NumpadDecimal'), - (t[(t.NumpadDivide = 108)] = 'NumpadDivide'), - (t[(t.KEY_IN_COMPOSITION = 109)] = 'KEY_IN_COMPOSITION'), - (t[(t.ABNT_C1 = 110)] = 'ABNT_C1'), - (t[(t.ABNT_C2 = 111)] = 'ABNT_C2'), - (t[(t.AudioVolumeMute = 112)] = 'AudioVolumeMute'), - (t[(t.AudioVolumeUp = 113)] = 'AudioVolumeUp'), - (t[(t.AudioVolumeDown = 114)] = 'AudioVolumeDown'), - (t[(t.BrowserSearch = 115)] = 'BrowserSearch'), - (t[(t.BrowserHome = 116)] = 'BrowserHome'), - (t[(t.BrowserBack = 117)] = 'BrowserBack'), - (t[(t.BrowserForward = 118)] = 'BrowserForward'), - (t[(t.MediaTrackNext = 119)] = 'MediaTrackNext'), - (t[(t.MediaTrackPrevious = 120)] = 'MediaTrackPrevious'), - (t[(t.MediaStop = 121)] = 'MediaStop'), - (t[(t.MediaPlayPause = 122)] = 'MediaPlayPause'), - (t[(t.LaunchMediaPlayer = 123)] = 'LaunchMediaPlayer'), - (t[(t.LaunchMail = 124)] = 'LaunchMail'), - (t[(t.LaunchApp2 = 125)] = 'LaunchApp2'), - (t[(t.Clear = 126)] = 'Clear'), - (t[(t.MAX_VALUE = 127)] = 'MAX_VALUE') - })((d = n.KeyCode || (n.KeyCode = {}))) - var C - ;(function (t) { - ;(t[(t.Hint = 1)] = 'Hint'), - (t[(t.Info = 2)] = 'Info'), - (t[(t.Warning = 4)] = 'Warning'), - (t[(t.Error = 8)] = 'Error') - })((C = n.MarkerSeverity || (n.MarkerSeverity = {}))) - var r - ;(function (t) { - ;(t[(t.Unnecessary = 1)] = 'Unnecessary'), (t[(t.Deprecated = 2)] = 'Deprecated') - })((r = n.MarkerTag || (n.MarkerTag = {}))) - var u - ;(function (t) { - ;(t[(t.Inline = 1)] = 'Inline'), (t[(t.Gutter = 2)] = 'Gutter') - })((u = n.MinimapPosition || (n.MinimapPosition = {}))) - var o - ;(function (t) { - ;(t[(t.UNKNOWN = 0)] = 'UNKNOWN'), - (t[(t.TEXTAREA = 1)] = 'TEXTAREA'), - (t[(t.GUTTER_GLYPH_MARGIN = 2)] = 'GUTTER_GLYPH_MARGIN'), - (t[(t.GUTTER_LINE_NUMBERS = 3)] = 'GUTTER_LINE_NUMBERS'), - (t[(t.GUTTER_LINE_DECORATIONS = 4)] = 'GUTTER_LINE_DECORATIONS'), - (t[(t.GUTTER_VIEW_ZONE = 5)] = 'GUTTER_VIEW_ZONE'), - (t[(t.CONTENT_TEXT = 6)] = 'CONTENT_TEXT'), - (t[(t.CONTENT_EMPTY = 7)] = 'CONTENT_EMPTY'), - (t[(t.CONTENT_VIEW_ZONE = 8)] = 'CONTENT_VIEW_ZONE'), - (t[(t.CONTENT_WIDGET = 9)] = 'CONTENT_WIDGET'), - (t[(t.OVERVIEW_RULER = 10)] = 'OVERVIEW_RULER'), - (t[(t.SCROLLBAR = 11)] = 'SCROLLBAR'), - (t[(t.OVERLAY_WIDGET = 12)] = 'OVERLAY_WIDGET'), - (t[(t.OUTSIDE_EDITOR = 13)] = 'OUTSIDE_EDITOR') - })((o = n.MouseTargetType || (n.MouseTargetType = {}))) - var c - ;(function (t) { - ;(t[(t.TOP_RIGHT_CORNER = 0)] = 'TOP_RIGHT_CORNER'), - (t[(t.BOTTOM_RIGHT_CORNER = 1)] = 'BOTTOM_RIGHT_CORNER'), - (t[(t.TOP_CENTER = 2)] = 'TOP_CENTER') - })((c = n.OverlayWidgetPositionPreference || (n.OverlayWidgetPositionPreference = {}))) - var l - ;(function (t) { - ;(t[(t.Left = 1)] = 'Left'), - (t[(t.Center = 2)] = 'Center'), - (t[(t.Right = 4)] = 'Right'), - (t[(t.Full = 7)] = 'Full') - })((l = n.OverviewRulerLane || (n.OverviewRulerLane = {}))) - var m - ;(function (t) { - ;(t[(t.Left = 0)] = 'Left'), - (t[(t.Right = 1)] = 'Right'), - (t[(t.None = 2)] = 'None'), - (t[(t.LeftOfInjectedText = 3)] = 'LeftOfInjectedText'), - (t[(t.RightOfInjectedText = 4)] = 'RightOfInjectedText') - })((m = n.PositionAffinity || (n.PositionAffinity = {}))) - var N - ;(function (t) { - ;(t[(t.Off = 0)] = 'Off'), - (t[(t.On = 1)] = 'On'), - (t[(t.Relative = 2)] = 'Relative'), - (t[(t.Interval = 3)] = 'Interval'), - (t[(t.Custom = 4)] = 'Custom') - })((N = n.RenderLineNumbersType || (n.RenderLineNumbersType = {}))) - var A - ;(function (t) { - ;(t[(t.None = 0)] = 'None'), (t[(t.Text = 1)] = 'Text'), (t[(t.Blocks = 2)] = 'Blocks') - })((A = n.RenderMinimap || (n.RenderMinimap = {}))) - var M - ;(function (t) { - ;(t[(t.Smooth = 0)] = 'Smooth'), (t[(t.Immediate = 1)] = 'Immediate') - })((M = n.ScrollType || (n.ScrollType = {}))) - var k - ;(function (t) { - ;(t[(t.Auto = 1)] = 'Auto'), - (t[(t.Hidden = 2)] = 'Hidden'), - (t[(t.Visible = 3)] = 'Visible') - })((k = n.ScrollbarVisibility || (n.ScrollbarVisibility = {}))) - var q - ;(function (t) { - ;(t[(t.LTR = 0)] = 'LTR'), (t[(t.RTL = 1)] = 'RTL') - })((q = n.SelectionDirection || (n.SelectionDirection = {}))) - var I - ;(function (t) { - ;(t[(t.Invoke = 1)] = 'Invoke'), - (t[(t.TriggerCharacter = 2)] = 'TriggerCharacter'), - (t[(t.ContentChange = 3)] = 'ContentChange') - })((I = n.SignatureHelpTriggerKind || (n.SignatureHelpTriggerKind = {}))) - var B - ;(function (t) { - ;(t[(t.File = 0)] = 'File'), - (t[(t.Module = 1)] = 'Module'), - (t[(t.Namespace = 2)] = 'Namespace'), - (t[(t.Package = 3)] = 'Package'), - (t[(t.Class = 4)] = 'Class'), - (t[(t.Method = 5)] = 'Method'), - (t[(t.Property = 6)] = 'Property'), - (t[(t.Field = 7)] = 'Field'), - (t[(t.Constructor = 8)] = 'Constructor'), - (t[(t.Enum = 9)] = 'Enum'), - (t[(t.Interface = 10)] = 'Interface'), - (t[(t.Function = 11)] = 'Function'), - (t[(t.Variable = 12)] = 'Variable'), - (t[(t.Constant = 13)] = 'Constant'), - (t[(t.String = 14)] = 'String'), - (t[(t.Number = 15)] = 'Number'), - (t[(t.Boolean = 16)] = 'Boolean'), - (t[(t.Array = 17)] = 'Array'), - (t[(t.Object = 18)] = 'Object'), - (t[(t.Key = 19)] = 'Key'), - (t[(t.Null = 20)] = 'Null'), - (t[(t.EnumMember = 21)] = 'EnumMember'), - (t[(t.Struct = 22)] = 'Struct'), - (t[(t.Event = 23)] = 'Event'), - (t[(t.Operator = 24)] = 'Operator'), - (t[(t.TypeParameter = 25)] = 'TypeParameter') - })((B = n.SymbolKind || (n.SymbolKind = {}))) - var H - ;(function (t) { - t[(t.Deprecated = 1)] = 'Deprecated' - })((H = n.SymbolTag || (n.SymbolTag = {}))) - var F - ;(function (t) { - ;(t[(t.Hidden = 0)] = 'Hidden'), - (t[(t.Blink = 1)] = 'Blink'), - (t[(t.Smooth = 2)] = 'Smooth'), - (t[(t.Phase = 3)] = 'Phase'), - (t[(t.Expand = 4)] = 'Expand'), - (t[(t.Solid = 5)] = 'Solid') - })((F = n.TextEditorCursorBlinkingStyle || (n.TextEditorCursorBlinkingStyle = {}))) - var U - ;(function (t) { - ;(t[(t.Line = 1)] = 'Line'), - (t[(t.Block = 2)] = 'Block'), - (t[(t.Underline = 3)] = 'Underline'), - (t[(t.LineThin = 4)] = 'LineThin'), - (t[(t.BlockOutline = 5)] = 'BlockOutline'), - (t[(t.UnderlineThin = 6)] = 'UnderlineThin') - })((U = n.TextEditorCursorStyle || (n.TextEditorCursorStyle = {}))) - var T - ;(function (t) { - ;(t[(t.AlwaysGrowsWhenTypingAtEdges = 0)] = 'AlwaysGrowsWhenTypingAtEdges'), - (t[(t.NeverGrowsWhenTypingAtEdges = 1)] = 'NeverGrowsWhenTypingAtEdges'), - (t[(t.GrowsOnlyWhenTypingBefore = 2)] = 'GrowsOnlyWhenTypingBefore'), - (t[(t.GrowsOnlyWhenTypingAfter = 3)] = 'GrowsOnlyWhenTypingAfter') - })((T = n.TrackedRangeStickiness || (n.TrackedRangeStickiness = {}))) - var W - ;(function (t) { - ;(t[(t.None = 0)] = 'None'), - (t[(t.Same = 1)] = 'Same'), - (t[(t.Indent = 2)] = 'Indent'), - (t[(t.DeepIndent = 3)] = 'DeepIndent') - })((W = n.WrappingIndent || (n.WrappingIndent = {}))) - }), - Q(Y[50], X([25, 57]), function (x, n) { - return x.create('vs/base/common/platform', n) - }), - Q(Y[8], X([0, 1, 50]), function (x, n, R) { - 'use strict' - var D - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.isAndroid = - n.isEdge = - n.isSafari = - n.isFirefox = - n.isChrome = - n.isLittleEndian = - n.OS = - n.setTimeout0 = - n.setTimeout0IsFaster = - n.language = - n.userAgent = - n.isMobile = - n.isIOS = - n.isWebWorker = - n.isWeb = - n.isNative = - n.isLinux = - n.isMacintosh = - n.isWindows = - n.globals = - n.LANGUAGE_DEFAULT = - void 0), - (n.LANGUAGE_DEFAULT = 'en') - let i = !1, - s = !1, - p = !1, - L = !1, - h = !1, - a = !1, - w = !1, - e = !1, - b = !1, - f = !1, - v, - g = n.LANGUAGE_DEFAULT, - S = n.LANGUAGE_DEFAULT, - E, - y - n.globals = typeof self == 'object' ? self : typeof global == 'object' ? global : {} - let _ - typeof n.globals.vscode < 'u' && typeof n.globals.vscode.process < 'u' - ? (_ = n.globals.vscode.process) - : typeof process < 'u' && (_ = process) - const d = - typeof ((D = _?.versions) === null || D === void 0 ? void 0 : D.electron) == 'string', - C = d && _?.type === 'renderer' - if (typeof navigator == 'object' && !C) - (y = navigator.userAgent), - (i = y.indexOf('Windows') >= 0), - (s = y.indexOf('Macintosh') >= 0), - (e = - (y.indexOf('Macintosh') >= 0 || y.indexOf('iPad') >= 0 || y.indexOf('iPhone') >= 0) && - !!navigator.maxTouchPoints && - navigator.maxTouchPoints > 0), - (p = y.indexOf('Linux') >= 0), - (f = y?.indexOf('Mobi') >= 0), - (a = !0), - (v = R.getConfiguredDefaultLocale(R.localize(0, null)) || n.LANGUAGE_DEFAULT), - (g = v), - (S = navigator.language) - else if (typeof _ == 'object') { - ;(i = _.platform === 'win32'), - (s = _.platform === 'darwin'), - (p = _.platform === 'linux'), - (L = p && !!_.env.SNAP && !!_.env.SNAP_REVISION), - (w = d), - (b = !!_.env.CI || !!_.env.BUILD_ARTIFACTSTAGINGDIRECTORY), - (v = n.LANGUAGE_DEFAULT), - (g = n.LANGUAGE_DEFAULT) - const l = _.env.VSCODE_NLS_CONFIG - if (l) - try { - const m = JSON.parse(l), - N = m.availableLanguages['*'] - ;(v = m.locale), - (S = m.osLocale), - (g = N || n.LANGUAGE_DEFAULT), - (E = m._translationsConfigFile) - } catch {} - h = !0 - } else console.error('Unable to resolve platform.') - let r = 0 - s ? (r = 1) : i ? (r = 3) : p && (r = 2), - (n.isWindows = i), - (n.isMacintosh = s), - (n.isLinux = p), - (n.isNative = h), - (n.isWeb = a), - (n.isWebWorker = a && typeof n.globals.importScripts == 'function'), - (n.isIOS = e), - (n.isMobile = f), - (n.userAgent = y), - (n.language = g), - (n.setTimeout0IsFaster = - typeof n.globals.postMessage == 'function' && !n.globals.importScripts), - (n.setTimeout0 = (() => { - if (n.setTimeout0IsFaster) { - const l = [] - n.globals.addEventListener('message', (N) => { - if (N.data && N.data.vscodeScheduleAsyncWork) - for (let A = 0, M = l.length; A < M; A++) { - const k = l[A] - if (k.id === N.data.vscodeScheduleAsyncWork) { - l.splice(A, 1), k.callback() - return - } - } - }) - let m = 0 - return (N) => { - const A = ++m - l.push({ id: A, callback: N }), - n.globals.postMessage({ vscodeScheduleAsyncWork: A }, '*') - } - } - return (l) => setTimeout(l) - })()), - (n.OS = s || e ? 2 : i ? 1 : 3) - let u = !0, - o = !1 - function c() { - if (!o) { - o = !0 - const l = new Uint8Array(2) - ;(l[0] = 1), (l[1] = 2), (u = new Uint16Array(l.buffer)[0] === (2 << 8) + 1) - } - return u - } - ;(n.isLittleEndian = c), - (n.isChrome = !!(n.userAgent && n.userAgent.indexOf('Chrome') >= 0)), - (n.isFirefox = !!(n.userAgent && n.userAgent.indexOf('Firefox') >= 0)), - (n.isSafari = !!(!n.isChrome && n.userAgent && n.userAgent.indexOf('Safari') >= 0)), - (n.isEdge = !!(n.userAgent && n.userAgent.indexOf('Edg/') >= 0)), - (n.isAndroid = !!(n.userAgent && n.userAgent.indexOf('Android') >= 0)) - }), - Q(Y[51], X([0, 1, 8]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.platform = n.env = n.cwd = void 0) - let D - if (typeof R.globals.vscode < 'u' && typeof R.globals.vscode.process < 'u') { - const i = R.globals.vscode.process - D = { - get platform() { - return i.platform - }, - get arch() { - return i.arch - }, - get env() { - return i.env - }, - cwd() { - return i.cwd() - }, - } - } else - typeof process < 'u' - ? (D = { - get platform() { - return process.platform - }, - get arch() { - return process.arch - }, - get env() { - return process.env - }, - cwd() { - return process.env.VSCODE_CWD || process.cwd() - }, - }) - : (D = { - get platform() { - return R.isWindows ? 'win32' : R.isMacintosh ? 'darwin' : 'linux' - }, - get arch() {}, - get env() { - return {} - }, - cwd() { - return '/' - }, - }) - ;(n.cwd = D.cwd), (n.env = D.env), (n.platform = D.platform) - }), - Q(Y[52], X([0, 1, 51]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.sep = - n.extname = - n.basename = - n.dirname = - n.relative = - n.resolve = - n.normalize = - n.posix = - n.win32 = - void 0) - const D = 65, - i = 97, - s = 90, - p = 122, - L = 46, - h = 47, - a = 92, - w = 58, - e = 63 - class b extends Error { - constructor(u, o, c) { - let l - typeof o == 'string' && o.indexOf('not ') === 0 - ? ((l = 'must not be'), (o = o.replace(/^not /, ''))) - : (l = 'must be') - const m = u.indexOf('.') !== -1 ? 'property' : 'argument' - let N = `The "${u}" ${m} ${l} of type ${o}` - ;(N += `. Received type ${typeof c}`), super(N), (this.code = 'ERR_INVALID_ARG_TYPE') - } - } - function f(r, u) { - if (r === null || typeof r != 'object') throw new b(u, 'Object', r) - } - function v(r, u) { - if (typeof r != 'string') throw new b(u, 'string', r) - } - const g = R.platform === 'win32' - function S(r) { - return r === h || r === a - } - function E(r) { - return r === h - } - function y(r) { - return (r >= D && r <= s) || (r >= i && r <= p) - } - function _(r, u, o, c) { - let l = '', - m = 0, - N = -1, - A = 0, - M = 0 - for (let k = 0; k <= r.length; ++k) { - if (k < r.length) M = r.charCodeAt(k) - else { - if (c(M)) break - M = h - } - if (c(M)) { - if (!(N === k - 1 || A === 1)) - if (A === 2) { - if ( - l.length < 2 || - m !== 2 || - l.charCodeAt(l.length - 1) !== L || - l.charCodeAt(l.length - 2) !== L - ) { - if (l.length > 2) { - const q = l.lastIndexOf(o) - q === -1 - ? ((l = ''), (m = 0)) - : ((l = l.slice(0, q)), (m = l.length - 1 - l.lastIndexOf(o))), - (N = k), - (A = 0) - continue - } else if (l.length !== 0) { - ;(l = ''), (m = 0), (N = k), (A = 0) - continue - } - } - u && ((l += l.length > 0 ? `${o}..` : '..'), (m = 2)) - } else - l.length > 0 ? (l += `${o}${r.slice(N + 1, k)}`) : (l = r.slice(N + 1, k)), - (m = k - N - 1) - ;(N = k), (A = 0) - } else M === L && A !== -1 ? ++A : (A = -1) - } - return l - } - function d(r, u) { - f(u, 'pathObject') - const o = u.dir || u.root, - c = u.base || `${u.name || ''}${u.ext || ''}` - return o ? (o === u.root ? `${o}${c}` : `${o}${r}${c}`) : c - } - n.win32 = { - resolve(...r) { - let u = '', - o = '', - c = !1 - for (let l = r.length - 1; l >= -1; l--) { - let m - if (l >= 0) { - if (((m = r[l]), v(m, 'path'), m.length === 0)) continue - } else - u.length === 0 - ? (m = R.cwd()) - : ((m = R.env[`=${u}`] || R.cwd()), - (m === void 0 || - (m.slice(0, 2).toLowerCase() !== u.toLowerCase() && m.charCodeAt(2) === a)) && - (m = `${u}\\`)) - const N = m.length - let A = 0, - M = '', - k = !1 - const q = m.charCodeAt(0) - if (N === 1) S(q) && ((A = 1), (k = !0)) - else if (S(q)) - if (((k = !0), S(m.charCodeAt(1)))) { - let I = 2, - B = I - for (; I < N && !S(m.charCodeAt(I)); ) I++ - if (I < N && I !== B) { - const H = m.slice(B, I) - for (B = I; I < N && S(m.charCodeAt(I)); ) I++ - if (I < N && I !== B) { - for (B = I; I < N && !S(m.charCodeAt(I)); ) I++ - ;(I === N || I !== B) && ((M = `\\\\${H}\\${m.slice(B, I)}`), (A = I)) - } - } - } else A = 1 - else - y(q) && - m.charCodeAt(1) === w && - ((M = m.slice(0, 2)), (A = 2), N > 2 && S(m.charCodeAt(2)) && ((k = !0), (A = 3))) - if (M.length > 0) - if (u.length > 0) { - if (M.toLowerCase() !== u.toLowerCase()) continue - } else u = M - if (c) { - if (u.length > 0) break - } else if (((o = `${m.slice(A)}\\${o}`), (c = k), k && u.length > 0)) break - } - return (o = _(o, !c, '\\', S)), c ? `${u}\\${o}` : `${u}${o}` || '.' - }, - normalize(r) { - v(r, 'path') - const u = r.length - if (u === 0) return '.' - let o = 0, - c, - l = !1 - const m = r.charCodeAt(0) - if (u === 1) return E(m) ? '\\' : r - if (S(m)) - if (((l = !0), S(r.charCodeAt(1)))) { - let A = 2, - M = A - for (; A < u && !S(r.charCodeAt(A)); ) A++ - if (A < u && A !== M) { - const k = r.slice(M, A) - for (M = A; A < u && S(r.charCodeAt(A)); ) A++ - if (A < u && A !== M) { - for (M = A; A < u && !S(r.charCodeAt(A)); ) A++ - if (A === u) return `\\\\${k}\\${r.slice(M)}\\` - A !== M && ((c = `\\\\${k}\\${r.slice(M, A)}`), (o = A)) - } - } - } else o = 1 - else - y(m) && - r.charCodeAt(1) === w && - ((c = r.slice(0, 2)), (o = 2), u > 2 && S(r.charCodeAt(2)) && ((l = !0), (o = 3))) - let N = o < u ? _(r.slice(o), !l, '\\', S) : '' - return ( - N.length === 0 && !l && (N = '.'), - N.length > 0 && S(r.charCodeAt(u - 1)) && (N += '\\'), - c === void 0 ? (l ? `\\${N}` : N) : l ? `${c}\\${N}` : `${c}${N}` - ) - }, - isAbsolute(r) { - v(r, 'path') - const u = r.length - if (u === 0) return !1 - const o = r.charCodeAt(0) - return S(o) || (u > 2 && y(o) && r.charCodeAt(1) === w && S(r.charCodeAt(2))) - }, - join(...r) { - if (r.length === 0) return '.' - let u, o - for (let m = 0; m < r.length; ++m) { - const N = r[m] - v(N, 'path'), N.length > 0 && (u === void 0 ? (u = o = N) : (u += `\\${N}`)) - } - if (u === void 0) return '.' - let c = !0, - l = 0 - if (typeof o == 'string' && S(o.charCodeAt(0))) { - ++l - const m = o.length - m > 1 && S(o.charCodeAt(1)) && (++l, m > 2 && (S(o.charCodeAt(2)) ? ++l : (c = !1))) - } - if (c) { - for (; l < u.length && S(u.charCodeAt(l)); ) l++ - l >= 2 && (u = `\\${u.slice(l)}`) - } - return n.win32.normalize(u) - }, - relative(r, u) { - if ((v(r, 'from'), v(u, 'to'), r === u)) return '' - const o = n.win32.resolve(r), - c = n.win32.resolve(u) - if (o === c || ((r = o.toLowerCase()), (u = c.toLowerCase()), r === u)) return '' - let l = 0 - for (; l < r.length && r.charCodeAt(l) === a; ) l++ - let m = r.length - for (; m - 1 > l && r.charCodeAt(m - 1) === a; ) m-- - const N = m - l - let A = 0 - for (; A < u.length && u.charCodeAt(A) === a; ) A++ - let M = u.length - for (; M - 1 > A && u.charCodeAt(M - 1) === a; ) M-- - const k = M - A, - q = N < k ? N : k - let I = -1, - B = 0 - for (; B < q; B++) { - const F = r.charCodeAt(l + B) - if (F !== u.charCodeAt(A + B)) break - F === a && (I = B) - } - if (B !== q) { - if (I === -1) return c - } else { - if (k > q) { - if (u.charCodeAt(A + B) === a) return c.slice(A + B + 1) - if (B === 2) return c.slice(A + B) - } - N > q && (r.charCodeAt(l + B) === a ? (I = B) : B === 2 && (I = 3)), I === -1 && (I = 0) - } - let H = '' - for (B = l + I + 1; B <= m; ++B) - (B === m || r.charCodeAt(B) === a) && (H += H.length === 0 ? '..' : '\\..') - return ( - (A += I), - H.length > 0 ? `${H}${c.slice(A, M)}` : (c.charCodeAt(A) === a && ++A, c.slice(A, M)) - ) - }, - toNamespacedPath(r) { - if (typeof r != 'string' || r.length === 0) return r - const u = n.win32.resolve(r) - if (u.length <= 2) return r - if (u.charCodeAt(0) === a) { - if (u.charCodeAt(1) === a) { - const o = u.charCodeAt(2) - if (o !== e && o !== L) return `\\\\?\\UNC\\${u.slice(2)}` - } - } else if (y(u.charCodeAt(0)) && u.charCodeAt(1) === w && u.charCodeAt(2) === a) - return `\\\\?\\${u}` - return r - }, - dirname(r) { - v(r, 'path') - const u = r.length - if (u === 0) return '.' - let o = -1, - c = 0 - const l = r.charCodeAt(0) - if (u === 1) return S(l) ? r : '.' - if (S(l)) { - if (((o = c = 1), S(r.charCodeAt(1)))) { - let A = 2, - M = A - for (; A < u && !S(r.charCodeAt(A)); ) A++ - if (A < u && A !== M) { - for (M = A; A < u && S(r.charCodeAt(A)); ) A++ - if (A < u && A !== M) { - for (M = A; A < u && !S(r.charCodeAt(A)); ) A++ - if (A === u) return r - A !== M && (o = c = A + 1) - } - } - } - } else - y(l) && r.charCodeAt(1) === w && ((o = u > 2 && S(r.charCodeAt(2)) ? 3 : 2), (c = o)) - let m = -1, - N = !0 - for (let A = u - 1; A >= c; --A) - if (S(r.charCodeAt(A))) { - if (!N) { - m = A - break - } - } else N = !1 - if (m === -1) { - if (o === -1) return '.' - m = o - } - return r.slice(0, m) - }, - basename(r, u) { - u !== void 0 && v(u, 'ext'), v(r, 'path') - let o = 0, - c = -1, - l = !0, - m - if ( - (r.length >= 2 && y(r.charCodeAt(0)) && r.charCodeAt(1) === w && (o = 2), - u !== void 0 && u.length > 0 && u.length <= r.length) - ) { - if (u === r) return '' - let N = u.length - 1, - A = -1 - for (m = r.length - 1; m >= o; --m) { - const M = r.charCodeAt(m) - if (S(M)) { - if (!l) { - o = m + 1 - break - } - } else - A === -1 && ((l = !1), (A = m + 1)), - N >= 0 && (M === u.charCodeAt(N) ? --N === -1 && (c = m) : ((N = -1), (c = A))) - } - return o === c ? (c = A) : c === -1 && (c = r.length), r.slice(o, c) - } - for (m = r.length - 1; m >= o; --m) - if (S(r.charCodeAt(m))) { - if (!l) { - o = m + 1 - break - } - } else c === -1 && ((l = !1), (c = m + 1)) - return c === -1 ? '' : r.slice(o, c) - }, - extname(r) { - v(r, 'path') - let u = 0, - o = -1, - c = 0, - l = -1, - m = !0, - N = 0 - r.length >= 2 && r.charCodeAt(1) === w && y(r.charCodeAt(0)) && (u = c = 2) - for (let A = r.length - 1; A >= u; --A) { - const M = r.charCodeAt(A) - if (S(M)) { - if (!m) { - c = A + 1 - break - } - continue - } - l === -1 && ((m = !1), (l = A + 1)), - M === L ? (o === -1 ? (o = A) : N !== 1 && (N = 1)) : o !== -1 && (N = -1) - } - return o === -1 || l === -1 || N === 0 || (N === 1 && o === l - 1 && o === c + 1) - ? '' - : r.slice(o, l) - }, - format: d.bind(null, '\\'), - parse(r) { - v(r, 'path') - const u = { root: '', dir: '', base: '', ext: '', name: '' } - if (r.length === 0) return u - const o = r.length - let c = 0, - l = r.charCodeAt(0) - if (o === 1) return S(l) ? ((u.root = u.dir = r), u) : ((u.base = u.name = r), u) - if (S(l)) { - if (((c = 1), S(r.charCodeAt(1)))) { - let I = 2, - B = I - for (; I < o && !S(r.charCodeAt(I)); ) I++ - if (I < o && I !== B) { - for (B = I; I < o && S(r.charCodeAt(I)); ) I++ - if (I < o && I !== B) { - for (B = I; I < o && !S(r.charCodeAt(I)); ) I++ - I === o ? (c = I) : I !== B && (c = I + 1) - } - } - } - } else if (y(l) && r.charCodeAt(1) === w) { - if (o <= 2) return (u.root = u.dir = r), u - if (((c = 2), S(r.charCodeAt(2)))) { - if (o === 3) return (u.root = u.dir = r), u - c = 3 - } - } - c > 0 && (u.root = r.slice(0, c)) - let m = -1, - N = c, - A = -1, - M = !0, - k = r.length - 1, - q = 0 - for (; k >= c; --k) { - if (((l = r.charCodeAt(k)), S(l))) { - if (!M) { - N = k + 1 - break - } - continue - } - A === -1 && ((M = !1), (A = k + 1)), - l === L ? (m === -1 ? (m = k) : q !== 1 && (q = 1)) : m !== -1 && (q = -1) - } - return ( - A !== -1 && - (m === -1 || q === 0 || (q === 1 && m === A - 1 && m === N + 1) - ? (u.base = u.name = r.slice(N, A)) - : ((u.name = r.slice(N, m)), (u.base = r.slice(N, A)), (u.ext = r.slice(m, A)))), - N > 0 && N !== c ? (u.dir = r.slice(0, N - 1)) : (u.dir = u.root), - u - ) - }, - sep: '\\', - delimiter: ';', - win32: null, - posix: null, - } - const C = (() => { - if (g) { - const r = /\\/g - return () => { - const u = R.cwd().replace(r, '/') - return u.slice(u.indexOf('/')) - } - } - return () => R.cwd() - })() - ;(n.posix = { - resolve(...r) { - let u = '', - o = !1 - for (let c = r.length - 1; c >= -1 && !o; c--) { - const l = c >= 0 ? r[c] : C() - v(l, 'path'), l.length !== 0 && ((u = `${l}/${u}`), (o = l.charCodeAt(0) === h)) - } - return (u = _(u, !o, '/', E)), o ? `/${u}` : u.length > 0 ? u : '.' - }, - normalize(r) { - if ((v(r, 'path'), r.length === 0)) return '.' - const u = r.charCodeAt(0) === h, - o = r.charCodeAt(r.length - 1) === h - return ( - (r = _(r, !u, '/', E)), - r.length === 0 ? (u ? '/' : o ? './' : '.') : (o && (r += '/'), u ? `/${r}` : r) - ) - }, - isAbsolute(r) { - return v(r, 'path'), r.length > 0 && r.charCodeAt(0) === h - }, - join(...r) { - if (r.length === 0) return '.' - let u - for (let o = 0; o < r.length; ++o) { - const c = r[o] - v(c, 'path'), c.length > 0 && (u === void 0 ? (u = c) : (u += `/${c}`)) - } - return u === void 0 ? '.' : n.posix.normalize(u) - }, - relative(r, u) { - if ( - (v(r, 'from'), - v(u, 'to'), - r === u || ((r = n.posix.resolve(r)), (u = n.posix.resolve(u)), r === u)) - ) - return '' - const o = 1, - c = r.length, - l = c - o, - m = 1, - N = u.length - m, - A = l < N ? l : N - let M = -1, - k = 0 - for (; k < A; k++) { - const I = r.charCodeAt(o + k) - if (I !== u.charCodeAt(m + k)) break - I === h && (M = k) - } - if (k === A) - if (N > A) { - if (u.charCodeAt(m + k) === h) return u.slice(m + k + 1) - if (k === 0) return u.slice(m + k) - } else l > A && (r.charCodeAt(o + k) === h ? (M = k) : k === 0 && (M = 0)) - let q = '' - for (k = o + M + 1; k <= c; ++k) - (k === c || r.charCodeAt(k) === h) && (q += q.length === 0 ? '..' : '/..') - return `${q}${u.slice(m + M)}` - }, - toNamespacedPath(r) { - return r - }, - dirname(r) { - if ((v(r, 'path'), r.length === 0)) return '.' - const u = r.charCodeAt(0) === h - let o = -1, - c = !0 - for (let l = r.length - 1; l >= 1; --l) - if (r.charCodeAt(l) === h) { - if (!c) { - o = l - break - } - } else c = !1 - return o === -1 ? (u ? '/' : '.') : u && o === 1 ? '//' : r.slice(0, o) - }, - basename(r, u) { - u !== void 0 && v(u, 'ext'), v(r, 'path') - let o = 0, - c = -1, - l = !0, - m - if (u !== void 0 && u.length > 0 && u.length <= r.length) { - if (u === r) return '' - let N = u.length - 1, - A = -1 - for (m = r.length - 1; m >= 0; --m) { - const M = r.charCodeAt(m) - if (M === h) { - if (!l) { - o = m + 1 - break - } - } else - A === -1 && ((l = !1), (A = m + 1)), - N >= 0 && (M === u.charCodeAt(N) ? --N === -1 && (c = m) : ((N = -1), (c = A))) - } - return o === c ? (c = A) : c === -1 && (c = r.length), r.slice(o, c) - } - for (m = r.length - 1; m >= 0; --m) - if (r.charCodeAt(m) === h) { - if (!l) { - o = m + 1 - break - } - } else c === -1 && ((l = !1), (c = m + 1)) - return c === -1 ? '' : r.slice(o, c) - }, - extname(r) { - v(r, 'path') - let u = -1, - o = 0, - c = -1, - l = !0, - m = 0 - for (let N = r.length - 1; N >= 0; --N) { - const A = r.charCodeAt(N) - if (A === h) { - if (!l) { - o = N + 1 - break - } - continue - } - c === -1 && ((l = !1), (c = N + 1)), - A === L ? (u === -1 ? (u = N) : m !== 1 && (m = 1)) : u !== -1 && (m = -1) - } - return u === -1 || c === -1 || m === 0 || (m === 1 && u === c - 1 && u === o + 1) - ? '' - : r.slice(u, c) - }, - format: d.bind(null, '/'), - parse(r) { - v(r, 'path') - const u = { root: '', dir: '', base: '', ext: '', name: '' } - if (r.length === 0) return u - const o = r.charCodeAt(0) === h - let c - o ? ((u.root = '/'), (c = 1)) : (c = 0) - let l = -1, - m = 0, - N = -1, - A = !0, - M = r.length - 1, - k = 0 - for (; M >= c; --M) { - const q = r.charCodeAt(M) - if (q === h) { - if (!A) { - m = M + 1 - break - } - continue - } - N === -1 && ((A = !1), (N = M + 1)), - q === L ? (l === -1 ? (l = M) : k !== 1 && (k = 1)) : l !== -1 && (k = -1) - } - if (N !== -1) { - const q = m === 0 && o ? 1 : m - l === -1 || k === 0 || (k === 1 && l === N - 1 && l === m + 1) - ? (u.base = u.name = r.slice(q, N)) - : ((u.name = r.slice(q, l)), (u.base = r.slice(q, N)), (u.ext = r.slice(l, N))) - } - return m > 0 ? (u.dir = r.slice(0, m - 1)) : o && (u.dir = '/'), u - }, - sep: '/', - delimiter: ':', - win32: null, - posix: null, - }), - (n.posix.win32 = n.win32.win32 = n.win32), - (n.posix.posix = n.win32.posix = n.posix), - (n.normalize = g ? n.win32.normalize : n.posix.normalize), - (n.resolve = g ? n.win32.resolve : n.posix.resolve), - (n.relative = g ? n.win32.relative : n.posix.relative), - (n.dirname = g ? n.win32.dirname : n.posix.dirname), - (n.basename = g ? n.win32.basename : n.posix.basename), - (n.extname = g ? n.win32.extname : n.posix.extname), - (n.sep = g ? n.win32.sep : n.posix.sep) - }), - Q(Y[24], X([0, 1, 8]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.StopWatch = void 0) - const D = R.globals.performance && typeof R.globals.performance.now == 'function' - class i { - static create(p = !0) { - return new i(p) - } - constructor(p) { - ;(this._highResolution = D && p), (this._startTime = this._now()), (this._stopTime = -1) - } - stop() { - this._stopTime = this._now() - } - elapsed() { - return this._stopTime !== -1 - ? this._stopTime - this._startTime - : this._now() - this._startTime - } - _now() { - return this._highResolution ? R.globals.performance.now() : Date.now() - } - } - n.StopWatch = i - }), - Q(Y[9], X([0, 1, 4, 14, 11, 16, 24]), function (x, n, R, D, i, s, p) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.Relay = - n.EventBufferer = - n.EventMultiplexer = - n.MicrotaskEmitter = - n.DebounceEmitter = - n.PauseableEmitter = - n.EventDeliveryQueue = - n.Emitter = - n.EventProfiling = - n.Event = - void 0) - const L = !1, - h = !1 - var a - ;(function (c) { - c.None = () => i.Disposable.None - function l(J) { - if (h) { - const { onDidAddListener: z } = J, - G = f.create() - let $ = 0 - J.onDidAddListener = () => { - ++$ === 2 && - (console.warn( - 'snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here' - ), - G.print()), - z?.() - } - } - } - function m(J, z) { - return F(J, () => {}, 0, void 0, !0, void 0, z) - } - c.defer = m - function N(J) { - return (z, G = null, $) => { - let K = !1, - ee - return ( - (ee = J( - (ne) => { - if (!K) return ee ? ee.dispose() : (K = !0), z.call(G, ne) - }, - null, - $ - )), - K && ee.dispose(), - ee - ) - } - } - c.once = N - function A(J, z, G) { - return H(($, K = null, ee) => J((ne) => $.call(K, z(ne)), null, ee), G) - } - c.map = A - function M(J, z, G) { - return H( - ($, K = null, ee) => - J( - (ne) => { - z(ne), $.call(K, ne) - }, - null, - ee - ), - G - ) - } - c.forEach = M - function k(J, z, G) { - return H(($, K = null, ee) => J((ne) => z(ne) && $.call(K, ne), null, ee), G) - } - c.filter = k - function q(J) { - return J - } - c.signal = q - function I(...J) { - return (z, G = null, $) => - (0, i.combinedDisposable)(...J.map((K) => K((ee) => z.call(G, ee), null, $))) - } - c.any = I - function B(J, z, G, $) { - let K = G - return A(J, (ee) => ((K = z(K, ee)), K), $) - } - c.reduce = B - function H(J, z) { - let G - const $ = { - onWillAddFirstListener() { - G = J(K.fire, K) - }, - onDidRemoveLastListener() { - G?.dispose() - }, - } - z || l($) - const K = new g($) - return z?.add(K), K.event - } - function F(J, z, G = 100, $ = !1, K = !1, ee, ne) { - let ce, - Se, - we, - fe = 0, - me - const P = { - leakWarningThreshold: ee, - onWillAddFirstListener() { - ce = J((V) => { - fe++, - (Se = z(Se, V)), - $ && !we && (O.fire(Se), (Se = void 0)), - (me = () => { - const j = Se - ;(Se = void 0), (we = void 0), (!$ || fe > 1) && O.fire(j), (fe = 0) - }), - typeof G == 'number' - ? (clearTimeout(we), (we = setTimeout(me, G))) - : we === void 0 && ((we = 0), queueMicrotask(me)) - }) - }, - onWillRemoveListener() { - K && fe > 0 && me?.() - }, - onDidRemoveLastListener() { - ;(me = void 0), ce.dispose() - }, - } - ne || l(P) - const O = new g(P) - return ne?.add(O), O.event - } - c.debounce = F - function U(J, z = 0, G) { - return c.debounce(J, ($, K) => ($ ? ($.push(K), $) : [K]), z, void 0, !0, void 0, G) - } - c.accumulate = U - function T(J, z = ($, K) => $ === K, G) { - let $ = !0, - K - return k( - J, - (ee) => { - const ne = $ || !z(ee, K) - return ($ = !1), (K = ee), ne - }, - G - ) - } - c.latch = T - function W(J, z, G) { - return [c.filter(J, z, G), c.filter(J, ($) => !z($), G)] - } - c.split = W - function t(J, z = !1, G = []) { - let $ = G.slice(), - K = J((ce) => { - $ ? $.push(ce) : ne.fire(ce) - }) - const ee = () => { - $?.forEach((ce) => ne.fire(ce)), ($ = null) - }, - ne = new g({ - onWillAddFirstListener() { - K || (K = J((ce) => ne.fire(ce))) - }, - onDidAddFirstListener() { - $ && (z ? setTimeout(ee) : ee()) - }, - onDidRemoveLastListener() { - K && K.dispose(), (K = null) - }, - }) - return ne.event - } - c.buffer = t - class te { - constructor(z) { - ;(this.event = z), (this.disposables = new i.DisposableStore()) - } - map(z) { - return new te(A(this.event, z, this.disposables)) - } - forEach(z) { - return new te(M(this.event, z, this.disposables)) - } - filter(z) { - return new te(k(this.event, z, this.disposables)) - } - reduce(z, G) { - return new te(B(this.event, z, G, this.disposables)) - } - latch() { - return new te(T(this.event, void 0, this.disposables)) - } - debounce(z, G = 100, $ = !1, K = !1, ee) { - return new te(F(this.event, z, G, $, K, ee, this.disposables)) - } - on(z, G, $) { - return this.event(z, G, $) - } - once(z, G, $) { - return N(this.event)(z, G, $) - } - dispose() { - this.disposables.dispose() - } - } - function ie(J) { - return new te(J) - } - c.chain = ie - function ue(J, z, G = ($) => $) { - const $ = (...ce) => ne.fire(G(...ce)), - K = () => J.on(z, $), - ee = () => J.removeListener(z, $), - ne = new g({ onWillAddFirstListener: K, onDidRemoveLastListener: ee }) - return ne.event - } - c.fromNodeEventEmitter = ue - function de(J, z, G = ($) => $) { - const $ = (...ce) => ne.fire(G(...ce)), - K = () => J.addEventListener(z, $), - ee = () => J.removeEventListener(z, $), - ne = new g({ onWillAddFirstListener: K, onDidRemoveLastListener: ee }) - return ne.event - } - c.fromDOMEventEmitter = de - function Ce(J) { - return new Promise((z) => N(J)(z)) - } - c.toPromise = Ce - function re(J, z) { - return z(void 0), J((G) => z(G)) - } - c.runAndSubscribe = re - function se(J, z) { - let G = null - function $(ee) { - G?.dispose(), (G = new i.DisposableStore()), z(ee, G) - } - $(void 0) - const K = J((ee) => $(ee)) - return (0, i.toDisposable)(() => { - K.dispose(), G?.dispose() - }) - } - c.runAndSubscribeWithStore = se - class ge { - constructor(z, G) { - ;(this.obs = z), (this._counter = 0), (this._hasChanged = !1) - const $ = { - onWillAddFirstListener: () => { - z.addObserver(this) - }, - onDidRemoveLastListener: () => { - z.removeObserver(this) - }, - } - G || l($), (this.emitter = new g($)), G && G.add(this.emitter) - } - beginUpdate(z) { - this._counter++ - } - handleChange(z, G) { - this._hasChanged = !0 - } - endUpdate(z) { - --this._counter === 0 && - this._hasChanged && - ((this._hasChanged = !1), this.emitter.fire(this.obs.get())) - } - } - function Le(J, z) { - return new ge(J, z).emitter.event - } - c.fromObservable = Le - })((a = n.Event || (n.Event = {}))) - class w { - constructor(l) { - ;(this.listenerCount = 0), - (this.invocationCount = 0), - (this.elapsedOverall = 0), - (this.durations = []), - (this.name = `${l}_${w._idPool++}`), - w.all.add(this) - } - start(l) { - ;(this._stopWatch = new p.StopWatch(!0)), (this.listenerCount = l) - } - stop() { - if (this._stopWatch) { - const l = this._stopWatch.elapsed() - this.durations.push(l), - (this.elapsedOverall += l), - (this.invocationCount += 1), - (this._stopWatch = void 0) - } - } - } - ;(w.all = new Set()), (w._idPool = 0), (n.EventProfiling = w) - let e = -1 - class b { - constructor(l, m = Math.random().toString(18).slice(2, 5)) { - ;(this.threshold = l), (this.name = m), (this._warnCountdown = 0) - } - dispose() { - var l - ;(l = this._stacks) === null || l === void 0 || l.clear() - } - check(l, m) { - const N = this.threshold - if (N <= 0 || m < N) return - this._stacks || (this._stacks = new Map()) - const A = this._stacks.get(l.value) || 0 - if ( - (this._stacks.set(l.value, A + 1), (this._warnCountdown -= 1), this._warnCountdown <= 0) - ) { - this._warnCountdown = N * 0.5 - let M, - k = 0 - for (const [q, I] of this._stacks) (!M || k < I) && ((M = q), (k = I)) - console.warn( - `[${this.name}] potential listener LEAK detected, having ${m} listeners already. MOST frequent listener (${k}):` - ), - console.warn(M) - } - return () => { - const M = this._stacks.get(l.value) || 0 - this._stacks.set(l.value, M - 1) - } - } - } - class f { - static create() { - var l - return new f((l = new Error().stack) !== null && l !== void 0 ? l : '') - } - constructor(l) { - this.value = l - } - print() { - console.warn( - this.value - .split( - ` -` - ) - .slice(2).join(` -`) - ) - } - } - class v { - constructor(l, m, N) { - ;(this.callback = l), - (this.callbackThis = m), - (this.stack = N), - (this.subscription = new i.SafeDisposable()) - } - invoke(l) { - this.callback.call(this.callbackThis, l) - } - } - class g { - constructor(l) { - var m, N, A, M, k - ;(this._disposed = !1), - (this._options = l), - (this._leakageMon = - e > 0 || - ((m = this._options) === null || m === void 0 ? void 0 : m.leakWarningThreshold) - ? new b( - (A = - (N = this._options) === null || N === void 0 - ? void 0 - : N.leakWarningThreshold) !== null && A !== void 0 - ? A - : e - ) - : void 0), - (this._perfMon = - !((M = this._options) === null || M === void 0) && M._profName - ? new w(this._options._profName) - : void 0), - (this._deliveryQueue = - (k = this._options) === null || k === void 0 ? void 0 : k.deliveryQueue) - } - dispose() { - var l, m, N, A - if (!this._disposed) { - if (((this._disposed = !0), this._listeners)) { - if (L) { - const M = Array.from(this._listeners) - queueMicrotask(() => { - var k - for (const q of M) - q.subscription.isset() && - (q.subscription.unset(), (k = q.stack) === null || k === void 0 || k.print()) - }) - } - this._listeners.clear() - } - ;(l = this._deliveryQueue) === null || l === void 0 || l.clear(this), - (N = - (m = this._options) === null || m === void 0 - ? void 0 - : m.onDidRemoveLastListener) === null || - N === void 0 || - N.call(m), - (A = this._leakageMon) === null || A === void 0 || A.dispose() - } - } - get event() { - return ( - this._event || - (this._event = (l, m, N) => { - var A, M, k - if ( - (this._listeners || (this._listeners = new s.LinkedList()), - this._leakageMon && this._listeners.size > this._leakageMon.threshold * 3) - ) - return ( - console.warn( - `[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far` - ), - i.Disposable.None - ) - const q = this._listeners.isEmpty() - q && - ((A = this._options) === null || A === void 0 - ? void 0 - : A.onWillAddFirstListener) && - this._options.onWillAddFirstListener(this) - let I, B - this._leakageMon && - this._listeners.size >= Math.ceil(this._leakageMon.threshold * 0.2) && - ((B = f.create()), (I = this._leakageMon.check(B, this._listeners.size + 1))), - L && (B = B ?? f.create()) - const H = new v(l, m, B), - F = this._listeners.push(H) - q && - ((M = this._options) === null || M === void 0 - ? void 0 - : M.onDidAddFirstListener) && - this._options.onDidAddFirstListener(this), - !((k = this._options) === null || k === void 0) && - k.onDidAddListener && - this._options.onDidAddListener(this, l, m) - const U = H.subscription.set(() => { - var T, W - I?.(), - this._disposed || - ((W = - (T = this._options) === null || T === void 0 - ? void 0 - : T.onWillRemoveListener) === null || - W === void 0 || - W.call(T, this), - F(), - this._options && - this._options.onDidRemoveLastListener && - ((this._listeners && !this._listeners.isEmpty()) || - this._options.onDidRemoveLastListener(this))) - }) - return N instanceof i.DisposableStore ? N.add(U) : Array.isArray(N) && N.push(U), U - }), - this._event - ) - } - fire(l) { - var m, N, A - if (this._listeners) { - this._deliveryQueue || - (this._deliveryQueue = new E( - (m = this._options) === null || m === void 0 ? void 0 : m.onListenerError - )) - for (const M of this._listeners) this._deliveryQueue.push(this, M, l) - ;(N = this._perfMon) === null || N === void 0 || N.start(this._deliveryQueue.size), - this._deliveryQueue.deliver(), - (A = this._perfMon) === null || A === void 0 || A.stop() - } - } - hasListeners() { - return this._listeners ? !this._listeners.isEmpty() : !1 - } - } - n.Emitter = g - class S { - constructor(l = R.onUnexpectedError) { - ;(this._onListenerError = l), (this._queue = new s.LinkedList()) - } - get size() { - return this._queue.size - } - push(l, m, N) { - this._queue.push(new y(l, m, N)) - } - clear(l) { - const m = new s.LinkedList() - for (const N of this._queue) N.emitter !== l && m.push(N) - this._queue = m - } - deliver() { - for (; this._queue.size > 0; ) { - const l = this._queue.shift() - try { - l.listener.invoke(l.event) - } catch (m) { - this._onListenerError(m) - } - } - } - } - n.EventDeliveryQueue = S - class E extends S { - clear(l) { - this._queue.clear() - } - } - class y { - constructor(l, m, N) { - ;(this.emitter = l), (this.listener = m), (this.event = N) - } - } - class _ extends g { - constructor(l) { - super(l), - (this._isPaused = 0), - (this._eventQueue = new s.LinkedList()), - (this._mergeFn = l?.merge) - } - pause() { - this._isPaused++ - } - resume() { - if (this._isPaused !== 0 && --this._isPaused === 0) - if (this._mergeFn) { - if (this._eventQueue.size > 0) { - const l = Array.from(this._eventQueue) - this._eventQueue.clear(), super.fire(this._mergeFn(l)) - } - } else - for (; !this._isPaused && this._eventQueue.size !== 0; ) - super.fire(this._eventQueue.shift()) - } - fire(l) { - this._listeners && (this._isPaused !== 0 ? this._eventQueue.push(l) : super.fire(l)) - } - } - n.PauseableEmitter = _ - class d extends _ { - constructor(l) { - var m - super(l), (this._delay = (m = l.delay) !== null && m !== void 0 ? m : 100) - } - fire(l) { - this._handle || - (this.pause(), - (this._handle = setTimeout(() => { - ;(this._handle = void 0), this.resume() - }, this._delay))), - super.fire(l) - } - } - n.DebounceEmitter = d - class C extends g { - constructor(l) { - super(l), (this._queuedEvents = []), (this._mergeFn = l?.merge) - } - fire(l) { - !this.hasListeners() || - (this._queuedEvents.push(l), - this._queuedEvents.length === 1 && - queueMicrotask(() => { - this._mergeFn - ? super.fire(this._mergeFn(this._queuedEvents)) - : this._queuedEvents.forEach((m) => super.fire(m)), - (this._queuedEvents = []) - })) - } - } - n.MicrotaskEmitter = C - class r { - constructor() { - ;(this.hasListeners = !1), - (this.events = []), - (this.emitter = new g({ - onWillAddFirstListener: () => this.onFirstListenerAdd(), - onDidRemoveLastListener: () => this.onLastListenerRemove(), - })) - } - get event() { - return this.emitter.event - } - add(l) { - const m = { event: l, listener: null } - this.events.push(m), this.hasListeners && this.hook(m) - const N = () => { - this.hasListeners && this.unhook(m) - const A = this.events.indexOf(m) - this.events.splice(A, 1) - } - return (0, i.toDisposable)((0, D.once)(N)) - } - onFirstListenerAdd() { - ;(this.hasListeners = !0), this.events.forEach((l) => this.hook(l)) - } - onLastListenerRemove() { - ;(this.hasListeners = !1), this.events.forEach((l) => this.unhook(l)) - } - hook(l) { - l.listener = l.event((m) => this.emitter.fire(m)) - } - unhook(l) { - l.listener && l.listener.dispose(), (l.listener = null) - } - dispose() { - this.emitter.dispose() - } - } - n.EventMultiplexer = r - class u { - constructor() { - this.buffers = [] - } - wrapEvent(l) { - return (m, N, A) => - l( - (M) => { - const k = this.buffers[this.buffers.length - 1] - k ? k.push(() => m.call(N, M)) : m.call(N, M) - }, - void 0, - A - ) - } - bufferEvents(l) { - const m = [] - this.buffers.push(m) - const N = l() - return this.buffers.pop(), m.forEach((A) => A()), N - } - } - n.EventBufferer = u - class o { - constructor() { - ;(this.listening = !1), - (this.inputEvent = a.None), - (this.inputEventListener = i.Disposable.None), - (this.emitter = new g({ - onDidAddFirstListener: () => { - ;(this.listening = !0), - (this.inputEventListener = this.inputEvent(this.emitter.fire, this.emitter)) - }, - onDidRemoveLastListener: () => { - ;(this.listening = !1), this.inputEventListener.dispose() - }, - })), - (this.event = this.emitter.event) - } - set input(l) { - ;(this.inputEvent = l), - this.listening && - (this.inputEventListener.dispose(), - (this.inputEventListener = l(this.emitter.fire, this.emitter))) - } - dispose() { - this.inputEventListener.dispose(), this.emitter.dispose() - } - } - n.Relay = o - }), - Q(Y[53], X([0, 1, 9]), function (x, n, R) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.CancellationTokenSource = n.CancellationToken = void 0) - const D = Object.freeze(function (L, h) { - const a = setTimeout(L.bind(h), 0) - return { - dispose() { - clearTimeout(a) - }, - } - }) - var i - ;(function (L) { - function h(a) { - return a === L.None || a === L.Cancelled || a instanceof s - ? !0 - : !a || typeof a != 'object' - ? !1 - : typeof a.isCancellationRequested == 'boolean' && - typeof a.onCancellationRequested == 'function' - } - ;(L.isCancellationToken = h), - (L.None = Object.freeze({ - isCancellationRequested: !1, - onCancellationRequested: R.Event.None, - })), - (L.Cancelled = Object.freeze({ isCancellationRequested: !0, onCancellationRequested: D })) - })((i = n.CancellationToken || (n.CancellationToken = {}))) - class s { - constructor() { - ;(this._isCancelled = !1), (this._emitter = null) - } - cancel() { - this._isCancelled || - ((this._isCancelled = !0), - this._emitter && (this._emitter.fire(void 0), this.dispose())) - } - get isCancellationRequested() { - return this._isCancelled - } - get onCancellationRequested() { - return this._isCancelled - ? D - : (this._emitter || (this._emitter = new R.Emitter()), this._emitter.event) - } - dispose() { - this._emitter && (this._emitter.dispose(), (this._emitter = null)) - } - } - class p { - constructor(h) { - ;(this._token = void 0), - (this._parentListener = void 0), - (this._parentListener = h && h.onCancellationRequested(this.cancel, this)) - } - get token() { - return this._token || (this._token = new s()), this._token - } - cancel() { - this._token - ? this._token instanceof s && this._token.cancel() - : (this._token = i.Cancelled) - } - dispose(h = !1) { - var a - h && this.cancel(), - (a = this._parentListener) === null || a === void 0 || a.dispose(), - this._token ? this._token instanceof s && this._token.dispose() : (this._token = i.None) - } - } - n.CancellationTokenSource = p - }), - Q(Y[13], X([0, 1, 52, 8]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.uriToFsPath = n.URI = void 0) - const i = /^\w[\w\d+.-]*$/, - s = /^\//, - p = /^\/\// - function L(o, c) { - if (!o.scheme && c) - throw new Error( - `[UriError]: Scheme is missing: {scheme: "", authority: "${o.authority}", path: "${o.path}", query: "${o.query}", fragment: "${o.fragment}"}` - ) - if (o.scheme && !i.test(o.scheme)) - throw new Error('[UriError]: Scheme contains illegal characters.') - if (o.path) { - if (o.authority) { - if (!s.test(o.path)) - throw new Error( - '[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character' - ) - } else if (p.test(o.path)) - throw new Error( - '[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")' - ) - } - } - function h(o, c) { - return !o && !c ? 'file' : o - } - function a(o, c) { - switch (o) { - case 'https': - case 'http': - case 'file': - c ? c[0] !== e && (c = e + c) : (c = e) - break - } - return c - } - const w = '', - e = '/', - b = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/ - class f { - static isUri(c) { - return c instanceof f - ? !0 - : c - ? typeof c.authority == 'string' && - typeof c.fragment == 'string' && - typeof c.path == 'string' && - typeof c.query == 'string' && - typeof c.scheme == 'string' && - typeof c.fsPath == 'string' && - typeof c.with == 'function' && - typeof c.toString == 'function' - : !1 - } - constructor(c, l, m, N, A, M = !1) { - typeof c == 'object' - ? ((this.scheme = c.scheme || w), - (this.authority = c.authority || w), - (this.path = c.path || w), - (this.query = c.query || w), - (this.fragment = c.fragment || w)) - : ((this.scheme = h(c, M)), - (this.authority = l || w), - (this.path = a(this.scheme, m || w)), - (this.query = N || w), - (this.fragment = A || w), - L(this, M)) - } - get fsPath() { - return _(this, !1) - } - with(c) { - if (!c) return this - let { scheme: l, authority: m, path: N, query: A, fragment: M } = c - return ( - l === void 0 ? (l = this.scheme) : l === null && (l = w), - m === void 0 ? (m = this.authority) : m === null && (m = w), - N === void 0 ? (N = this.path) : N === null && (N = w), - A === void 0 ? (A = this.query) : A === null && (A = w), - M === void 0 ? (M = this.fragment) : M === null && (M = w), - l === this.scheme && - m === this.authority && - N === this.path && - A === this.query && - M === this.fragment - ? this - : new g(l, m, N, A, M) - ) - } - static parse(c, l = !1) { - const m = b.exec(c) - return m - ? new g(m[2] || w, u(m[4] || w), u(m[5] || w), u(m[7] || w), u(m[9] || w), l) - : new g(w, w, w, w, w) - } - static file(c) { - let l = w - if ((D.isWindows && (c = c.replace(/\\/g, e)), c[0] === e && c[1] === e)) { - const m = c.indexOf(e, 2) - m === -1 - ? ((l = c.substring(2)), (c = e)) - : ((l = c.substring(2, m)), (c = c.substring(m) || e)) - } - return new g('file', l, c, w, w) - } - static from(c) { - const l = new g(c.scheme, c.authority, c.path, c.query, c.fragment) - return L(l, !0), l - } - static joinPath(c, ...l) { - if (!c.path) throw new Error('[UriError]: cannot call joinPath on URI without path') - let m - return ( - D.isWindows && c.scheme === 'file' - ? (m = f.file(R.win32.join(_(c, !0), ...l)).path) - : (m = R.posix.join(c.path, ...l)), - c.with({ path: m }) - ) - } - toString(c = !1) { - return d(this, c) - } - toJSON() { - return this - } - static revive(c) { - if (c) { - if (c instanceof f) return c - { - const l = new g(c) - return (l._formatted = c.external), (l._fsPath = c._sep === v ? c.fsPath : null), l - } - } else return c - } - } - n.URI = f - const v = D.isWindows ? 1 : void 0 - class g extends f { - constructor() { - super(...arguments), (this._formatted = null), (this._fsPath = null) - } - get fsPath() { - return this._fsPath || (this._fsPath = _(this, !1)), this._fsPath - } - toString(c = !1) { - return c - ? d(this, !0) - : (this._formatted || (this._formatted = d(this, !1)), this._formatted) - } - toJSON() { - const c = { $mid: 1 } - return ( - this._fsPath && ((c.fsPath = this._fsPath), (c._sep = v)), - this._formatted && (c.external = this._formatted), - this.path && (c.path = this.path), - this.scheme && (c.scheme = this.scheme), - this.authority && (c.authority = this.authority), - this.query && (c.query = this.query), - this.fragment && (c.fragment = this.fragment), - c - ) - } - } - const S = { - [58]: '%3A', - [47]: '%2F', - [63]: '%3F', - [35]: '%23', - [91]: '%5B', - [93]: '%5D', - [64]: '%40', - [33]: '%21', - [36]: '%24', - [38]: '%26', - [39]: '%27', - [40]: '%28', - [41]: '%29', - [42]: '%2A', - [43]: '%2B', - [44]: '%2C', - [59]: '%3B', - [61]: '%3D', - [32]: '%20', - } - function E(o, c, l) { - let m, - N = -1 - for (let A = 0; A < o.length; A++) { - const M = o.charCodeAt(A) - if ( - (M >= 97 && M <= 122) || - (M >= 65 && M <= 90) || - (M >= 48 && M <= 57) || - M === 45 || - M === 46 || - M === 95 || - M === 126 || - (c && M === 47) || - (l && M === 91) || - (l && M === 93) || - (l && M === 58) - ) - N !== -1 && ((m += encodeURIComponent(o.substring(N, A))), (N = -1)), - m !== void 0 && (m += o.charAt(A)) - else { - m === void 0 && (m = o.substr(0, A)) - const k = S[M] - k !== void 0 - ? (N !== -1 && ((m += encodeURIComponent(o.substring(N, A))), (N = -1)), (m += k)) - : N === -1 && (N = A) - } - } - return N !== -1 && (m += encodeURIComponent(o.substring(N))), m !== void 0 ? m : o - } - function y(o) { - let c - for (let l = 0; l < o.length; l++) { - const m = o.charCodeAt(l) - m === 35 || m === 63 - ? (c === void 0 && (c = o.substr(0, l)), (c += S[m])) - : c !== void 0 && (c += o[l]) - } - return c !== void 0 ? c : o - } - function _(o, c) { - let l - return ( - o.authority && o.path.length > 1 && o.scheme === 'file' - ? (l = `//${o.authority}${o.path}`) - : o.path.charCodeAt(0) === 47 && - ((o.path.charCodeAt(1) >= 65 && o.path.charCodeAt(1) <= 90) || - (o.path.charCodeAt(1) >= 97 && o.path.charCodeAt(1) <= 122)) && - o.path.charCodeAt(2) === 58 - ? c - ? (l = o.path.substr(1)) - : (l = o.path[1].toLowerCase() + o.path.substr(2)) - : (l = o.path), - D.isWindows && (l = l.replace(/\//g, '\\')), - l - ) - } - n.uriToFsPath = _ - function d(o, c) { - const l = c ? y : E - let m = '', - { scheme: N, authority: A, path: M, query: k, fragment: q } = o - if ((N && ((m += N), (m += ':')), (A || N === 'file') && ((m += e), (m += e)), A)) { - let I = A.indexOf('@') - if (I !== -1) { - const B = A.substr(0, I) - ;(A = A.substr(I + 1)), - (I = B.lastIndexOf(':')), - I === -1 - ? (m += l(B, !1, !1)) - : ((m += l(B.substr(0, I), !1, !1)), (m += ':'), (m += l(B.substr(I + 1), !1, !0))), - (m += '@') - } - ;(A = A.toLowerCase()), - (I = A.lastIndexOf(':')), - I === -1 ? (m += l(A, !1, !0)) : ((m += l(A.substr(0, I), !1, !0)), (m += A.substr(I))) - } - if (M) { - if (M.length >= 3 && M.charCodeAt(0) === 47 && M.charCodeAt(2) === 58) { - const I = M.charCodeAt(1) - I >= 65 && I <= 90 && (M = `/${String.fromCharCode(I + 32)}:${M.substr(3)}`) - } else if (M.length >= 2 && M.charCodeAt(1) === 58) { - const I = M.charCodeAt(0) - I >= 65 && I <= 90 && (M = `${String.fromCharCode(I + 32)}:${M.substr(2)}`) - } - m += l(M, !0, !1) - } - return ( - k && ((m += '?'), (m += l(k, !1, !1))), q && ((m += '#'), (m += c ? q : E(q, !1, !1))), m - ) - } - function C(o) { - try { - return decodeURIComponent(o) - } catch { - return o.length > 3 ? o.substr(0, 3) + C(o.substr(3)) : o - } - } - const r = /(%[0-9A-Za-z][0-9A-Za-z])+/g - function u(o) { - return o.match(r) ? o.replace(r, (c) => C(c)) : o - } - }), - Q(Y[58], X([0, 1, 4, 9, 11, 12, 8, 5]), function (x, n, R, D, i, s, p, L) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.create = - n.SimpleWorkerServer = - n.SimpleWorkerClient = - n.logOnceWebWorkerWarning = - void 0) - const h = '$initialize' - let a = !1 - function w(u) { - !p.isWeb || - (a || - ((a = !0), - console.warn( - 'Could not create web worker(s). Falling back to loading web worker code in main thread, which might cause UI freezes. Please see https://github.com/microsoft/monaco-editor#faq' - )), - console.warn(u.message)) - } - n.logOnceWebWorkerWarning = w - class e { - constructor(o, c, l, m) { - ;(this.vsWorker = o), (this.req = c), (this.method = l), (this.args = m), (this.type = 0) - } - } - class b { - constructor(o, c, l, m) { - ;(this.vsWorker = o), (this.seq = c), (this.res = l), (this.err = m), (this.type = 1) - } - } - class f { - constructor(o, c, l, m) { - ;(this.vsWorker = o), - (this.req = c), - (this.eventName = l), - (this.arg = m), - (this.type = 2) - } - } - class v { - constructor(o, c, l) { - ;(this.vsWorker = o), (this.req = c), (this.event = l), (this.type = 3) - } - } - class g { - constructor(o, c) { - ;(this.vsWorker = o), (this.req = c), (this.type = 4) - } - } - class S { - constructor(o) { - ;(this._workerId = -1), - (this._handler = o), - (this._lastSentReq = 0), - (this._pendingReplies = Object.create(null)), - (this._pendingEmitters = new Map()), - (this._pendingEvents = new Map()) - } - setWorkerId(o) { - this._workerId = o - } - sendMessage(o, c) { - const l = String(++this._lastSentReq) - return new Promise((m, N) => { - ;(this._pendingReplies[l] = { resolve: m, reject: N }), - this._send(new e(this._workerId, l, o, c)) - }) - } - listen(o, c) { - let l = null - const m = new D.Emitter({ - onWillAddFirstListener: () => { - ;(l = String(++this._lastSentReq)), - this._pendingEmitters.set(l, m), - this._send(new f(this._workerId, l, o, c)) - }, - onDidRemoveLastListener: () => { - this._pendingEmitters.delete(l), this._send(new g(this._workerId, l)), (l = null) - }, - }) - return m.event - } - handleMessage(o) { - !o || - !o.vsWorker || - (this._workerId !== -1 && o.vsWorker !== this._workerId) || - this._handleMessage(o) - } - _handleMessage(o) { - switch (o.type) { - case 1: - return this._handleReplyMessage(o) - case 0: - return this._handleRequestMessage(o) - case 2: - return this._handleSubscribeEventMessage(o) - case 3: - return this._handleEventMessage(o) - case 4: - return this._handleUnsubscribeEventMessage(o) - } - } - _handleReplyMessage(o) { - if (!this._pendingReplies[o.seq]) { - console.warn('Got reply to unknown seq') - return - } - const c = this._pendingReplies[o.seq] - if ((delete this._pendingReplies[o.seq], o.err)) { - let l = o.err - o.err.$isError && - ((l = new Error()), - (l.name = o.err.name), - (l.message = o.err.message), - (l.stack = o.err.stack)), - c.reject(l) - return - } - c.resolve(o.res) - } - _handleRequestMessage(o) { - const c = o.req - this._handler.handleMessage(o.method, o.args).then( - (m) => { - this._send(new b(this._workerId, c, m, void 0)) - }, - (m) => { - m.detail instanceof Error && - (m.detail = (0, R.transformErrorForSerialization)(m.detail)), - this._send( - new b(this._workerId, c, void 0, (0, R.transformErrorForSerialization)(m)) - ) - } - ) - } - _handleSubscribeEventMessage(o) { - const c = o.req, - l = this._handler.handleEvent( - o.eventName, - o.arg - )((m) => { - this._send(new v(this._workerId, c, m)) - }) - this._pendingEvents.set(c, l) - } - _handleEventMessage(o) { - if (!this._pendingEmitters.has(o.req)) { - console.warn('Got event for unknown req') - return - } - this._pendingEmitters.get(o.req).fire(o.event) - } - _handleUnsubscribeEventMessage(o) { - if (!this._pendingEvents.has(o.req)) { - console.warn('Got unsubscribe for unknown req') - return - } - this._pendingEvents.get(o.req).dispose(), this._pendingEvents.delete(o.req) - } - _send(o) { - const c = [] - if (o.type === 0) - for (let l = 0; l < o.args.length; l++) - o.args[l] instanceof ArrayBuffer && c.push(o.args[l]) - else o.type === 1 && o.res instanceof ArrayBuffer && c.push(o.res) - this._handler.sendMessage(o, c) - } - } - class E extends i.Disposable { - constructor(o, c, l) { - super() - let m = null - ;(this._worker = this._register( - o.create( - 'vs/base/common/worker/simpleWorker', - (I) => { - this._protocol.handleMessage(I) - }, - (I) => { - m?.(I) - } - ) - )), - (this._protocol = new S({ - sendMessage: (I, B) => { - this._worker.postMessage(I, B) - }, - handleMessage: (I, B) => { - if (typeof l[I] != 'function') - return Promise.reject(new Error('Missing method ' + I + ' on main thread host.')) - try { - return Promise.resolve(l[I].apply(l, B)) - } catch (H) { - return Promise.reject(H) - } - }, - handleEvent: (I, B) => { - if (_(I)) { - const H = l[I].call(l, B) - if (typeof H != 'function') - throw new Error(`Missing dynamic event ${I} on main thread host.`) - return H - } - if (y(I)) { - const H = l[I] - if (typeof H != 'function') - throw new Error(`Missing event ${I} on main thread host.`) - return H - } - throw new Error(`Malformed event name ${I}`) - }, - })), - this._protocol.setWorkerId(this._worker.getId()) - let N = null - const A = globalThis.require - typeof A < 'u' && typeof A.getConfig == 'function' - ? (N = A.getConfig()) - : typeof globalThis.requirejs < 'u' && (N = globalThis.requirejs.s.contexts._.config) - const M = (0, s.getAllMethodNames)(l) - this._onModuleLoaded = this._protocol.sendMessage(h, [ - this._worker.getId(), - JSON.parse(JSON.stringify(N)), - c, - M, - ]) - const k = (I, B) => this._request(I, B), - q = (I, B) => this._protocol.listen(I, B) - this._lazyProxy = new Promise((I, B) => { - ;(m = B), - this._onModuleLoaded.then( - (H) => { - I(d(H, k, q)) - }, - (H) => { - B(H), this._onError('Worker failed to load ' + c, H) - } - ) - }) - } - getProxyObject() { - return this._lazyProxy - } - _request(o, c) { - return new Promise((l, m) => { - this._onModuleLoaded.then(() => { - this._protocol.sendMessage(o, c).then(l, m) - }, m) - }) - } - _onError(o, c) { - console.error(o), console.info(c) - } - } - n.SimpleWorkerClient = E - function y(u) { - return u[0] === 'o' && u[1] === 'n' && L.isUpperAsciiLetter(u.charCodeAt(2)) - } - function _(u) { - return /^onDynamic/.test(u) && L.isUpperAsciiLetter(u.charCodeAt(9)) - } - function d(u, o, c) { - const l = (A) => - function () { - const M = Array.prototype.slice.call(arguments, 0) - return o(A, M) - }, - m = (A) => - function (M) { - return c(A, M) - }, - N = {} - for (const A of u) { - if (_(A)) { - N[A] = m(A) - continue - } - if (y(A)) { - N[A] = c(A, void 0) - continue - } - N[A] = l(A) - } - return N - } - class C { - constructor(o, c) { - ;(this._requestHandlerFactory = c), - (this._requestHandler = null), - (this._protocol = new S({ - sendMessage: (l, m) => { - o(l, m) - }, - handleMessage: (l, m) => this._handleMessage(l, m), - handleEvent: (l, m) => this._handleEvent(l, m), - })) - } - onmessage(o) { - this._protocol.handleMessage(o) - } - _handleMessage(o, c) { - if (o === h) return this.initialize(c[0], c[1], c[2], c[3]) - if (!this._requestHandler || typeof this._requestHandler[o] != 'function') - return Promise.reject(new Error('Missing requestHandler or method: ' + o)) - try { - return Promise.resolve(this._requestHandler[o].apply(this._requestHandler, c)) - } catch (l) { - return Promise.reject(l) - } - } - _handleEvent(o, c) { - if (!this._requestHandler) throw new Error('Missing requestHandler') - if (_(o)) { - const l = this._requestHandler[o].call(this._requestHandler, c) - if (typeof l != 'function') - throw new Error(`Missing dynamic event ${o} on request handler.`) - return l - } - if (y(o)) { - const l = this._requestHandler[o] - if (typeof l != 'function') throw new Error(`Missing event ${o} on request handler.`) - return l - } - throw new Error(`Malformed event name ${o}`) - } - initialize(o, c, l, m) { - this._protocol.setWorkerId(o) - const M = d( - m, - (k, q) => this._protocol.sendMessage(k, q), - (k, q) => this._protocol.listen(k, q) - ) - return this._requestHandlerFactory - ? ((this._requestHandler = this._requestHandlerFactory(M)), - Promise.resolve((0, s.getAllMethodNames)(this._requestHandler))) - : (c && - (typeof c.baseUrl < 'u' && delete c.baseUrl, - typeof c.paths < 'u' && typeof c.paths.vs < 'u' && delete c.paths.vs, - typeof c.trustedTypesPolicy !== void 0 && delete c.trustedTypesPolicy, - (c.catchError = !0), - globalThis.require.config(c)), - new Promise((k, q) => { - ;(globalThis.require || x)( - [l], - (B) => { - if (((this._requestHandler = B.create(M)), !this._requestHandler)) { - q(new Error('No RequestHandler!')) - return - } - k((0, s.getAllMethodNames)(this._requestHandler)) - }, - q - ) - })) - } - } - n.SimpleWorkerServer = C - function r(u) { - return new C(u, null) - } - n.create = r - }), - Q(Y[54], X([0, 1, 9, 11]), function (x, n, R, D) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), (n.TokenizationRegistry = void 0) - class i { - constructor() { - ;(this._tokenizationSupports = new Map()), - (this._factories = new Map()), - (this._onDidChange = new R.Emitter()), - (this.onDidChange = this._onDidChange.event), - (this._colorMap = null) - } - handleChange(L) { - this._onDidChange.fire({ changedLanguages: L, changedColorMap: !1 }) - } - register(L, h) { - return ( - this._tokenizationSupports.set(L, h), - this.handleChange([L]), - (0, D.toDisposable)(() => { - this._tokenizationSupports.get(L) === h && - (this._tokenizationSupports.delete(L), this.handleChange([L])) - }) - ) - } - get(L) { - return this._tokenizationSupports.get(L) || null - } - registerFactory(L, h) { - var a - ;(a = this._factories.get(L)) === null || a === void 0 || a.dispose() - const w = new s(this, L, h) - return ( - this._factories.set(L, w), - (0, D.toDisposable)(() => { - const e = this._factories.get(L) - !e || e !== w || (this._factories.delete(L), e.dispose()) - }) - ) - } - getOrCreate(L) { - return be(this, void 0, void 0, function* () { - const h = this.get(L) - if (h) return h - const a = this._factories.get(L) - return !a || a.isResolved ? null : (yield a.resolve(), this.get(L)) - }) - } - isResolved(L) { - if (this.get(L)) return !0 - const a = this._factories.get(L) - return !!(!a || a.isResolved) - } - setColorMap(L) { - ;(this._colorMap = L), - this._onDidChange.fire({ - changedLanguages: Array.from(this._tokenizationSupports.keys()), - changedColorMap: !0, - }) - } - getColorMap() { - return this._colorMap - } - getDefaultBackground() { - return this._colorMap && this._colorMap.length > 2 ? this._colorMap[2] : null - } - } - n.TokenizationRegistry = i - class s extends D.Disposable { - get isResolved() { - return this._isResolved - } - constructor(L, h, a) { - super(), - (this._registry = L), - (this._languageId = h), - (this._factory = a), - (this._isDisposed = !1), - (this._resolvePromise = null), - (this._isResolved = !1) - } - dispose() { - ;(this._isDisposed = !0), super.dispose() - } - resolve() { - return be(this, void 0, void 0, function* () { - return ( - this._resolvePromise || (this._resolvePromise = this._create()), this._resolvePromise - ) - }) - } - _create() { - return be(this, void 0, void 0, function* () { - const L = yield this._factory.tokenizationSupport - ;(this._isResolved = !0), - L && !this._isDisposed && this._register(this._registry.register(this._languageId, L)) - }) - } - } - }), - Q(Y[55], X([0, 1, 32, 13, 2, 54]), function (x, n, R, D, i, s) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.TokenizationRegistry = - n.LazyTokenizationSupport = - n.InlayHintKind = - n.Command = - n.FoldingRangeKind = - n.SymbolKinds = - n.isLocationLink = - n.DocumentHighlightKind = - n.SignatureHelpTriggerKind = - n.InlineCompletionTriggerKind = - n.CompletionItemKinds = - n.EncodedTokenizationResult = - n.TokenizationResult = - n.Token = - void 0) - class p { - constructor(d, C, r) { - ;(this.offset = d), (this.type = C), (this.language = r), (this._tokenBrand = void 0) - } - toString() { - return '(' + this.offset + ', ' + this.type + ')' - } - } - n.Token = p - class L { - constructor(d, C) { - ;(this.tokens = d), (this.endState = C), (this._tokenizationResultBrand = void 0) - } - } - n.TokenizationResult = L - class h { - constructor(d, C) { - ;(this.tokens = d), (this.endState = C), (this._encodedTokenizationResultBrand = void 0) - } - } - n.EncodedTokenizationResult = h - var a - ;(function (_) { - const d = new Map() - d.set(0, R.Codicon.symbolMethod), - d.set(1, R.Codicon.symbolFunction), - d.set(2, R.Codicon.symbolConstructor), - d.set(3, R.Codicon.symbolField), - d.set(4, R.Codicon.symbolVariable), - d.set(5, R.Codicon.symbolClass), - d.set(6, R.Codicon.symbolStruct), - d.set(7, R.Codicon.symbolInterface), - d.set(8, R.Codicon.symbolModule), - d.set(9, R.Codicon.symbolProperty), - d.set(10, R.Codicon.symbolEvent), - d.set(11, R.Codicon.symbolOperator), - d.set(12, R.Codicon.symbolUnit), - d.set(13, R.Codicon.symbolValue), - d.set(15, R.Codicon.symbolEnum), - d.set(14, R.Codicon.symbolConstant), - d.set(15, R.Codicon.symbolEnum), - d.set(16, R.Codicon.symbolEnumMember), - d.set(17, R.Codicon.symbolKeyword), - d.set(27, R.Codicon.symbolSnippet), - d.set(18, R.Codicon.symbolText), - d.set(19, R.Codicon.symbolColor), - d.set(20, R.Codicon.symbolFile), - d.set(21, R.Codicon.symbolReference), - d.set(22, R.Codicon.symbolCustomColor), - d.set(23, R.Codicon.symbolFolder), - d.set(24, R.Codicon.symbolTypeParameter), - d.set(25, R.Codicon.account), - d.set(26, R.Codicon.issues) - function C(o) { - let c = d.get(o) - return ( - c || - (console.info('No codicon found for CompletionItemKind ' + o), - (c = R.Codicon.symbolProperty)), - c - ) - } - _.toIcon = C - const r = new Map() - r.set('method', 0), - r.set('function', 1), - r.set('constructor', 2), - r.set('field', 3), - r.set('variable', 4), - r.set('class', 5), - r.set('struct', 6), - r.set('interface', 7), - r.set('module', 8), - r.set('property', 9), - r.set('event', 10), - r.set('operator', 11), - r.set('unit', 12), - r.set('value', 13), - r.set('constant', 14), - r.set('enum', 15), - r.set('enum-member', 16), - r.set('enumMember', 16), - r.set('keyword', 17), - r.set('snippet', 27), - r.set('text', 18), - r.set('color', 19), - r.set('file', 20), - r.set('reference', 21), - r.set('customcolor', 22), - r.set('folder', 23), - r.set('type-parameter', 24), - r.set('typeParameter', 24), - r.set('account', 25), - r.set('issue', 26) - function u(o, c) { - let l = r.get(o) - return typeof l > 'u' && !c && (l = 9), l - } - _.fromString = u - })((a = n.CompletionItemKinds || (n.CompletionItemKinds = {}))) - var w - ;(function (_) { - ;(_[(_.Automatic = 0)] = 'Automatic'), (_[(_.Explicit = 1)] = 'Explicit') - })((w = n.InlineCompletionTriggerKind || (n.InlineCompletionTriggerKind = {}))) - var e - ;(function (_) { - ;(_[(_.Invoke = 1)] = 'Invoke'), - (_[(_.TriggerCharacter = 2)] = 'TriggerCharacter'), - (_[(_.ContentChange = 3)] = 'ContentChange') - })((e = n.SignatureHelpTriggerKind || (n.SignatureHelpTriggerKind = {}))) - var b - ;(function (_) { - ;(_[(_.Text = 0)] = 'Text'), (_[(_.Read = 1)] = 'Read'), (_[(_.Write = 2)] = 'Write') - })((b = n.DocumentHighlightKind || (n.DocumentHighlightKind = {}))) - function f(_) { - return ( - _ && - D.URI.isUri(_.uri) && - i.Range.isIRange(_.range) && - (i.Range.isIRange(_.originSelectionRange) || i.Range.isIRange(_.targetSelectionRange)) - ) - } - n.isLocationLink = f - var v - ;(function (_) { - const d = new Map() - d.set(0, R.Codicon.symbolFile), - d.set(1, R.Codicon.symbolModule), - d.set(2, R.Codicon.symbolNamespace), - d.set(3, R.Codicon.symbolPackage), - d.set(4, R.Codicon.symbolClass), - d.set(5, R.Codicon.symbolMethod), - d.set(6, R.Codicon.symbolProperty), - d.set(7, R.Codicon.symbolField), - d.set(8, R.Codicon.symbolConstructor), - d.set(9, R.Codicon.symbolEnum), - d.set(10, R.Codicon.symbolInterface), - d.set(11, R.Codicon.symbolFunction), - d.set(12, R.Codicon.symbolVariable), - d.set(13, R.Codicon.symbolConstant), - d.set(14, R.Codicon.symbolString), - d.set(15, R.Codicon.symbolNumber), - d.set(16, R.Codicon.symbolBoolean), - d.set(17, R.Codicon.symbolArray), - d.set(18, R.Codicon.symbolObject), - d.set(19, R.Codicon.symbolKey), - d.set(20, R.Codicon.symbolNull), - d.set(21, R.Codicon.symbolEnumMember), - d.set(22, R.Codicon.symbolStruct), - d.set(23, R.Codicon.symbolEvent), - d.set(24, R.Codicon.symbolOperator), - d.set(25, R.Codicon.symbolTypeParameter) - function C(r) { - let u = d.get(r) - return ( - u || - (console.info('No codicon found for SymbolKind ' + r), - (u = R.Codicon.symbolProperty)), - u - ) - } - _.toIcon = C - })((v = n.SymbolKinds || (n.SymbolKinds = {}))) - class g { - static fromValue(d) { - switch (d) { - case 'comment': - return g.Comment - case 'imports': - return g.Imports - case 'region': - return g.Region - } - return new g(d) - } - constructor(d) { - this.value = d - } - } - ;(g.Comment = new g('comment')), - (g.Imports = new g('imports')), - (g.Region = new g('region')), - (n.FoldingRangeKind = g) - var S - ;(function (_) { - function d(C) { - return !C || typeof C != 'object' - ? !1 - : typeof C.id == 'string' && typeof C.title == 'string' - } - _.is = d - })((S = n.Command || (n.Command = {}))) - var E - ;(function (_) { - ;(_[(_.Type = 1)] = 'Type'), (_[(_.Parameter = 2)] = 'Parameter') - })((E = n.InlayHintKind || (n.InlayHintKind = {}))) - class y { - constructor(d) { - ;(this.createSupport = d), (this._tokenizationSupport = null) - } - dispose() { - this._tokenizationSupport && - this._tokenizationSupport.then((d) => { - d && d.dispose() - }) - } - get tokenizationSupport() { - return ( - this._tokenizationSupport || (this._tokenizationSupport = this.createSupport()), - this._tokenizationSupport - ) - } - } - ;(n.LazyTokenizationSupport = y), (n.TokenizationRegistry = new s.TokenizationRegistry()) - }), - Q( - Y[56], - X([0, 1, 53, 9, 29, 13, 3, 2, 33, 55, 49]), - function (x, n, R, D, i, s, p, L, h, a, w) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.createMonacoBaseAPI = n.KeyMod = void 0) - class e { - static chord(v, g) { - return (0, i.KeyChord)(v, g) - } - } - ;(e.CtrlCmd = 2048), (e.Shift = 1024), (e.Alt = 512), (e.WinCtrl = 256), (n.KeyMod = e) - function b() { - return { - editor: void 0, - languages: void 0, - CancellationTokenSource: R.CancellationTokenSource, - Emitter: D.Emitter, - KeyCode: w.KeyCode, - KeyMod: e, - Position: p.Position, - Range: L.Range, - Selection: h.Selection, - SelectionDirection: w.SelectionDirection, - MarkerSeverity: w.MarkerSeverity, - MarkerTag: w.MarkerTag, - Uri: s.URI, - Token: a.Token, - } - } - n.createMonacoBaseAPI = b - } - ), - Q( - Y[59], - X([0, 1, 17, 13, 3, 2, 46, 22, 42, 43, 56, 24, 48, 41, 12]), - function (x, n, R, D, i, s, p, L, h, a, w, e, b, f, v) { - 'use strict' - Object.defineProperty(n, '__esModule', { value: !0 }), - (n.create = n.EditorSimpleWorker = void 0) - class g extends p.MirrorTextModel { - get uri() { - return this._uri - } - get eol() { - return this._eol - } - getValue() { - return this.getText() - } - getLinesContent() { - return this._lines.slice(0) - } - getLineCount() { - return this._lines.length - } - getLineContent(_) { - return this._lines[_ - 1] - } - getWordAtPosition(_, d) { - const C = (0, L.getWordAtText)( - _.column, - (0, L.ensureValidWordDefinition)(d), - this._lines[_.lineNumber - 1], - 0 - ) - return C ? new s.Range(_.lineNumber, C.startColumn, _.lineNumber, C.endColumn) : null - } - words(_) { - const d = this._lines, - C = this._wordenize.bind(this) - let r = 0, - u = '', - o = 0, - c = [] - return { - *[Symbol.iterator]() { - for (;;) - if (o < c.length) { - const l = u.substring(c[o].start, c[o].end) - ;(o += 1), yield l - } else if (r < d.length) (u = d[r]), (c = C(u, _)), (o = 0), (r += 1) - else break - }, - } - } - getLineWords(_, d) { - const C = this._lines[_ - 1], - r = this._wordenize(C, d), - u = [] - for (const o of r) - u.push({ - word: C.substring(o.start, o.end), - startColumn: o.start + 1, - endColumn: o.end + 1, - }) - return u - } - _wordenize(_, d) { - const C = [] - let r - for (d.lastIndex = 0; (r = d.exec(_)) && r[0].length !== 0; ) - C.push({ start: r.index, end: r.index + r[0].length }) - return C - } - getValueInRange(_) { - if (((_ = this._validateRange(_)), _.startLineNumber === _.endLineNumber)) - return this._lines[_.startLineNumber - 1].substring( - _.startColumn - 1, - _.endColumn - 1 - ) - const d = this._eol, - C = _.startLineNumber - 1, - r = _.endLineNumber - 1, - u = [] - u.push(this._lines[C].substring(_.startColumn - 1)) - for (let o = C + 1; o < r; o++) u.push(this._lines[o]) - return u.push(this._lines[r].substring(0, _.endColumn - 1)), u.join(d) - } - offsetAt(_) { - return ( - (_ = this._validatePosition(_)), - this._ensureLineStarts(), - this._lineStarts.getPrefixSum(_.lineNumber - 2) + (_.column - 1) - ) - } - positionAt(_) { - ;(_ = Math.floor(_)), (_ = Math.max(0, _)), this._ensureLineStarts() - const d = this._lineStarts.getIndexOf(_), - C = this._lines[d.index].length - return { lineNumber: 1 + d.index, column: 1 + Math.min(d.remainder, C) } - } - _validateRange(_) { - const d = this._validatePosition({ - lineNumber: _.startLineNumber, - column: _.startColumn, - }), - C = this._validatePosition({ lineNumber: _.endLineNumber, column: _.endColumn }) - return d.lineNumber !== _.startLineNumber || - d.column !== _.startColumn || - C.lineNumber !== _.endLineNumber || - C.column !== _.endColumn - ? { - startLineNumber: d.lineNumber, - startColumn: d.column, - endLineNumber: C.lineNumber, - endColumn: C.column, - } - : _ - } - _validatePosition(_) { - if (!i.Position.isIPosition(_)) throw new Error('bad position') - let { lineNumber: d, column: C } = _, - r = !1 - if (d < 1) (d = 1), (C = 1), (r = !0) - else if (d > this._lines.length) - (d = this._lines.length), (C = this._lines[d - 1].length + 1), (r = !0) - else { - const u = this._lines[d - 1].length + 1 - C < 1 ? ((C = 1), (r = !0)) : C > u && ((C = u), (r = !0)) - } - return r ? { lineNumber: d, column: C } : _ - } - } - class S { - constructor(_, d) { - ;(this._host = _), - (this._models = Object.create(null)), - (this._foreignModuleFactory = d), - (this._foreignModule = null) - } - dispose() { - this._models = Object.create(null) - } - _getModel(_) { - return this._models[_] - } - _getModels() { - const _ = [] - return Object.keys(this._models).forEach((d) => _.push(this._models[d])), _ - } - acceptNewModel(_) { - this._models[_.url] = new g(D.URI.parse(_.url), _.lines, _.EOL, _.versionId) - } - acceptModelChanged(_, d) { - if (!this._models[_]) return - this._models[_].onEvents(d) - } - acceptRemovedModel(_) { - !this._models[_] || delete this._models[_] - } - computeUnicodeHighlights(_, d, C) { - return be(this, void 0, void 0, function* () { - const r = this._getModel(_) - return r - ? b.UnicodeTextModelHighlighter.computeUnicodeHighlights(r, d, C) - : { - ranges: [], - hasMore: !1, - ambiguousCharacterCount: 0, - invisibleCharacterCount: 0, - nonBasicAsciiCharacterCount: 0, - } - }) - } - computeDiff(_, d, C, r) { - return be(this, void 0, void 0, function* () { - const u = this._getModel(_), - o = this._getModel(d) - return !u || !o ? null : S.computeDiff(u, o, C, r) - }) - } - static computeDiff(_, d, C, r) { - const u = - r === 'experimental' - ? f.linesDiffComputers.experimental - : f.linesDiffComputers.smart, - o = _.getLinesContent(), - c = d.getLinesContent(), - l = u.computeDiff(o, c, C) - return { - identical: l.changes.length > 0 ? !1 : this._modelsAreIdentical(_, d), - quitEarly: l.hitTimeout, - changes: l.changes.map((N) => { - var A - return [ - N.originalRange.startLineNumber, - N.originalRange.endLineNumberExclusive, - N.modifiedRange.startLineNumber, - N.modifiedRange.endLineNumberExclusive, - (A = N.innerChanges) === null || A === void 0 - ? void 0 - : A.map((M) => [ - M.originalRange.startLineNumber, - M.originalRange.startColumn, - M.originalRange.endLineNumber, - M.originalRange.endColumn, - M.modifiedRange.startLineNumber, - M.modifiedRange.startColumn, - M.modifiedRange.endLineNumber, - M.modifiedRange.endColumn, - ]), - ] - }), - } - } - static _modelsAreIdentical(_, d) { - const C = _.getLineCount(), - r = d.getLineCount() - if (C !== r) return !1 - for (let u = 1; u <= C; u++) { - const o = _.getLineContent(u), - c = d.getLineContent(u) - if (o !== c) return !1 - } - return !0 - } - computeMoreMinimalEdits(_, d, C) { - return be(this, void 0, void 0, function* () { - const r = this._getModel(_) - if (!r) return d - const u = [] - let o - d = d.slice(0).sort((c, l) => { - if (c.range && l.range) return s.Range.compareRangesUsingStarts(c.range, l.range) - const m = c.range ? 0 : 1, - N = l.range ? 0 : 1 - return m - N - }) - for (let { range: c, text: l, eol: m } of d) { - if ((typeof m == 'number' && (o = m), s.Range.isEmpty(c) && !l)) continue - const N = r.getValueInRange(c) - if (((l = l.replace(/\r\n|\n|\r/g, r.eol)), N === l)) continue - if (Math.max(l.length, N.length) > S._diffLimit) { - u.push({ range: c, text: l }) - continue - } - const A = (0, R.stringDiff)(N, l, C), - M = r.offsetAt(s.Range.lift(c).getStartPosition()) - for (const k of A) { - const q = r.positionAt(M + k.originalStart), - I = r.positionAt(M + k.originalStart + k.originalLength), - B = { - text: l.substr(k.modifiedStart, k.modifiedLength), - range: { - startLineNumber: q.lineNumber, - startColumn: q.column, - endLineNumber: I.lineNumber, - endColumn: I.column, - }, - } - r.getValueInRange(B.range) !== B.text && u.push(B) - } - } - return ( - typeof o == 'number' && - u.push({ - eol: o, - text: '', - range: { startLineNumber: 0, startColumn: 0, endLineNumber: 0, endColumn: 0 }, - }), - u - ) - }) - } - computeLinks(_) { - return be(this, void 0, void 0, function* () { - const d = this._getModel(_) - return d ? (0, h.computeLinks)(d) : null - }) - } - textualSuggest(_, d, C, r) { - return be(this, void 0, void 0, function* () { - const u = new e.StopWatch(!0), - o = new RegExp(C, r), - c = new Set() - e: for (const l of _) { - const m = this._getModel(l) - if (!!m) { - for (const N of m.words(o)) - if (!(N === d || !isNaN(Number(N))) && (c.add(N), c.size > S._suggestionsLimit)) - break e - } - } - return { words: Array.from(c), duration: u.elapsed() } - }) - } - computeWordRanges(_, d, C, r) { - return be(this, void 0, void 0, function* () { - const u = this._getModel(_) - if (!u) return Object.create(null) - const o = new RegExp(C, r), - c = Object.create(null) - for (let l = d.startLineNumber; l < d.endLineNumber; l++) { - const m = u.getLineWords(l, o) - for (const N of m) { - if (!isNaN(Number(N.word))) continue - let A = c[N.word] - A || ((A = []), (c[N.word] = A)), - A.push({ - startLineNumber: l, - startColumn: N.startColumn, - endLineNumber: l, - endColumn: N.endColumn, - }) - } - } - return c - }) - } - navigateValueSet(_, d, C, r, u) { - return be(this, void 0, void 0, function* () { - const o = this._getModel(_) - if (!o) return null - const c = new RegExp(r, u) - d.startColumn === d.endColumn && - (d = { - startLineNumber: d.startLineNumber, - startColumn: d.startColumn, - endLineNumber: d.endLineNumber, - endColumn: d.endColumn + 1, - }) - const l = o.getValueInRange(d), - m = o.getWordAtPosition({ lineNumber: d.startLineNumber, column: d.startColumn }, c) - if (!m) return null - const N = o.getValueInRange(m) - return a.BasicInplaceReplace.INSTANCE.navigateValueSet(d, l, m, N, C) - }) - } - loadForeignModule(_, d, C) { - const r = (c, l) => this._host.fhr(c, l), - o = { host: (0, v.createProxyObject)(C, r), getMirrorModels: () => this._getModels() } - return this._foreignModuleFactory - ? ((this._foreignModule = this._foreignModuleFactory(o, d)), - Promise.resolve((0, v.getAllMethodNames)(this._foreignModule))) - : new Promise((c, l) => { - x( - [_], - (m) => { - ;(this._foreignModule = m.create(o, d)), - c((0, v.getAllMethodNames)(this._foreignModule)) - }, - l - ) - }) - } - fmr(_, d) { - if (!this._foreignModule || typeof this._foreignModule[_] != 'function') - return Promise.reject(new Error('Missing requestHandler or method: ' + _)) - try { - return Promise.resolve(this._foreignModule[_].apply(this._foreignModule, d)) - } catch (C) { - return Promise.reject(C) - } - } - } - ;(S._diffLimit = 1e5), (S._suggestionsLimit = 1e4), (n.EditorSimpleWorker = S) - function E(y) { - return new S(y, null) - } - ;(n.create = E), - typeof importScripts == 'function' && (globalThis.monaco = (0, w.createMonacoBaseAPI)()) - } - ) -}).call(this) - -//# sourceMappingURL=../../../../min-maps/vs/base/worker/workerMain.js.map diff --git a/apps/studio/public/monaco-editor/basic-languages/html/html.js b/apps/studio/public/monaco-editor/basic-languages/html/html.js deleted file mode 100644 index d7ac6b4b191..00000000000 --- a/apps/studio/public/monaco-editor/basic-languages/html/html.js +++ /dev/null @@ -1,236 +0,0 @@ -'use strict' -/*!----------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Version: 0.37.0(14a92401d7aff24ad84578a4c8b9a701ff533a84) - * Released under the MIT license - * https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt - *-----------------------------------------------------------------------------*/ -define('vs/basic-languages/html/html', ['require', 'require'], (require) => { - var moduleExports = (() => { - var u = Object.create - var a = Object.defineProperty - var b = Object.getOwnPropertyDescriptor - var x = Object.getOwnPropertyNames - var y = Object.getPrototypeOf, - g = Object.prototype.hasOwnProperty - var k = ((e) => - typeof require != 'undefined' - ? require - : typeof Proxy != 'undefined' - ? new Proxy(e, { get: (t, n) => (typeof require != 'undefined' ? require : t)[n] }) - : e)(function (e) { - if (typeof require != 'undefined') return require.apply(this, arguments) - throw new Error('Dynamic require of "' + e + '" is not supported') - }) - var E = (e, t) => () => (t || e((t = { exports: {} }).exports, t), t.exports), - T = (e, t) => { - for (var n in t) a(e, n, { get: t[n], enumerable: !0 }) - }, - o = (e, t, n, s) => { - if ((t && typeof t == 'object') || typeof t == 'function') - for (let r of x(t)) - !g.call(e, r) && - r !== n && - a(e, r, { get: () => t[r], enumerable: !(s = b(t, r)) || s.enumerable }) - return e - }, - d = (e, t, n) => (o(e, t, 'default'), n && o(n, t, 'default')), - m = (e, t, n) => ( - (n = e != null ? u(y(e)) : {}), - o(t || !e || !e.__esModule ? a(n, 'default', { value: e, enumerable: !0 }) : n, e) - ), - w = (e) => o(a({}, '__esModule', { value: !0 }), e) - var l = E((A, p) => { - var h = m(k('vs/editor/editor.api')) - p.exports = h - }) - var $ = {} - T($, { conf: () => v, language: () => f }) - var i = {} - d(i, m(l())) - var c = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'keygen', - 'link', - 'menuitem', - 'meta', - 'param', - 'source', - 'track', - 'wbr', - ], - v = { - wordPattern: - /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g, - comments: { blockComment: [''] }, - brackets: [ - [''], - ['<', '>'], - ['{', '}'], - ['(', ')'], - ], - autoClosingPairs: [ - { open: '{', close: '}' }, - { open: '[', close: ']' }, - { open: '(', close: ')' }, - { open: '"', close: '"' }, - { open: "'", close: "'" }, - ], - surroundingPairs: [ - { open: '"', close: '"' }, - { open: "'", close: "'" }, - { open: '{', close: '}' }, - { open: '[', close: ']' }, - { open: '(', close: ')' }, - { open: '<', close: '>' }, - ], - onEnterRules: [ - { - beforeText: new RegExp( - `<(?!(?:${c.join('|')}))([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$`, - 'i' - ), - afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>$/i, - action: { indentAction: i.languages.IndentAction.IndentOutdent }, - }, - { - beforeText: new RegExp( - `<(?!(?:${c.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`, - 'i' - ), - action: { indentAction: i.languages.IndentAction.Indent }, - }, - ], - folding: { - markers: { - start: new RegExp('^\\s*'), - end: new RegExp('^\\s*'), - }, - }, - }, - f = { - defaultToken: '', - tokenPostfix: '.html', - ignoreCase: !0, - tokenizer: { - root: [ - [/)/, ['delimiter', 'tag', '', 'delimiter']], - [/(<)(script)/, ['delimiter', { token: 'tag', next: '@script' }]], - [/(<)(style)/, ['delimiter', { token: 'tag', next: '@style' }]], - [/(<)((?:[\w\-]+:)?[\w\-]+)/, ['delimiter', { token: 'tag', next: '@otherTag' }]], - [/(<\/)((?:[\w\-]+:)?[\w\-]+)/, ['delimiter', { token: 'tag', next: '@otherTag' }]], - [/]+/, 'metatag.content'], - [/>/, 'metatag', '@pop'], - ], - comment: [ - [/-->/, 'comment', '@pop'], - [/[^-]+/, 'comment.content'], - [/./, 'comment.content'], - ], - otherTag: [ - [/\/?>/, 'delimiter', '@pop'], - [/"([^"]*)"/, 'attribute.value'], - [/'([^']*)'/, 'attribute.value'], - [/[\w\-]+/, 'attribute.name'], - [/=/, 'delimiter'], - [/[ \t\r\n]+/], - ], - script: [ - [/type/, 'attribute.name', '@scriptAfterType'], - [/"([^"]*)"/, 'attribute.value'], - [/'([^']*)'/, 'attribute.value'], - [/[\w\-]+/, 'attribute.name'], - [/=/, 'delimiter'], - [/>/, { token: 'delimiter', next: '@scriptEmbedded', nextEmbedded: 'text/javascript' }], - [/[ \t\r\n]+/], - [/(<\/)(script\s*)(>)/, ['delimiter', 'tag', { token: 'delimiter', next: '@pop' }]], - ], - scriptAfterType: [ - [/=/, 'delimiter', '@scriptAfterTypeEquals'], - [/>/, { token: 'delimiter', next: '@scriptEmbedded', nextEmbedded: 'text/javascript' }], - [/[ \t\r\n]+/], - [/<\/script\s*>/, { token: '@rematch', next: '@pop' }], - ], - scriptAfterTypeEquals: [ - [ - /"module"/, - { token: 'attribute.value', switchTo: '@scriptWithCustomType.text/javascript' }, - ], - [ - /'module'/, - { token: 'attribute.value', switchTo: '@scriptWithCustomType.text/javascript' }, - ], - [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }], - [/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }], - [/>/, { token: 'delimiter', next: '@scriptEmbedded', nextEmbedded: 'text/javascript' }], - [/[ \t\r\n]+/], - [/<\/script\s*>/, { token: '@rematch', next: '@pop' }], - ], - scriptWithCustomType: [ - [/>/, { token: 'delimiter', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }], - [/"([^"]*)"/, 'attribute.value'], - [/'([^']*)'/, 'attribute.value'], - [/[\w\-]+/, 'attribute.name'], - [/=/, 'delimiter'], - [/[ \t\r\n]+/], - [/<\/script\s*>/, { token: '@rematch', next: '@pop' }], - ], - scriptEmbedded: [ - [/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }], - [/[^<]+/, ''], - ], - style: [ - [/type/, 'attribute.name', '@styleAfterType'], - [/"([^"]*)"/, 'attribute.value'], - [/'([^']*)'/, 'attribute.value'], - [/[\w\-]+/, 'attribute.name'], - [/=/, 'delimiter'], - [/>/, { token: 'delimiter', next: '@styleEmbedded', nextEmbedded: 'text/css' }], - [/[ \t\r\n]+/], - [/(<\/)(style\s*)(>)/, ['delimiter', 'tag', { token: 'delimiter', next: '@pop' }]], - ], - styleAfterType: [ - [/=/, 'delimiter', '@styleAfterTypeEquals'], - [/>/, { token: 'delimiter', next: '@styleEmbedded', nextEmbedded: 'text/css' }], - [/[ \t\r\n]+/], - [/<\/style\s*>/, { token: '@rematch', next: '@pop' }], - ], - styleAfterTypeEquals: [ - [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }], - [/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }], - [/>/, { token: 'delimiter', next: '@styleEmbedded', nextEmbedded: 'text/css' }], - [/[ \t\r\n]+/], - [/<\/style\s*>/, { token: '@rematch', next: '@pop' }], - ], - styleWithCustomType: [ - [/>/, { token: 'delimiter', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }], - [/"([^"]*)"/, 'attribute.value'], - [/'([^']*)'/, 'attribute.value'], - [/[\w\-]+/, 'attribute.name'], - [/=/, 'delimiter'], - [/[ \t\r\n]+/], - [/<\/style\s*>/, { token: '@rematch', next: '@pop' }], - ], - styleEmbedded: [ - [/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }], - [/[^<]+/, ''], - ], - }, - } - return w($) - })() - return moduleExports -}) diff --git a/apps/studio/public/monaco-editor/basic-languages/pgsql/pgsql.js b/apps/studio/public/monaco-editor/basic-languages/pgsql/pgsql.js deleted file mode 100644 index 202f0efadf0..00000000000 --- a/apps/studio/public/monaco-editor/basic-languages/pgsql/pgsql.js +++ /dev/null @@ -1,861 +0,0 @@ -'use strict' -/*!----------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Version: 0.37.0(14a92401d7aff24ad84578a4c8b9a701ff533a84) - * Released under the MIT license - * https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt - *-----------------------------------------------------------------------------*/ -define('vs/basic-languages/pgsql/pgsql', ['require', 'require'], (require) => { - var moduleExports = (() => { - var r = Object.defineProperty - var o = Object.getOwnPropertyDescriptor - var i = Object.getOwnPropertyNames - var n = Object.prototype.hasOwnProperty - var p = (_, e) => { - for (var s in e) r(_, s, { get: e[s], enumerable: !0 }) - }, - l = (_, e, s, a) => { - if ((e && typeof e == 'object') || typeof e == 'function') - for (let t of i(e)) - !n.call(_, t) && - t !== s && - r(_, t, { get: () => e[t], enumerable: !(a = o(e, t)) || a.enumerable }) - return _ - } - var g = (_) => l(r({}, '__esModule', { value: !0 }), _) - var m = {} - p(m, { conf: () => c, language: () => d }) - var c = { - comments: { lineComment: '--', blockComment: ['/*', '*/'] }, - brackets: [ - ['{', '}'], - ['[', ']'], - ['(', ')'], - ], - autoClosingPairs: [ - { open: '{', close: '}' }, - { open: '[', close: ']' }, - { open: '(', close: ')' }, - { open: '"', close: '"' }, - { open: "'", close: "'" }, - ], - surroundingPairs: [ - { open: '{', close: '}' }, - { open: '[', close: ']' }, - { open: '(', close: ')' }, - { open: '"', close: '"' }, - { open: "'", close: "'" }, - ], - }, - d = { - defaultToken: '', - tokenPostfix: '.sql', - ignoreCase: !0, - brackets: [ - { open: '[', close: ']', token: 'delimiter.square' }, - { open: '(', close: ')', token: 'delimiter.parenthesis' }, - ], - keywords: [ - 'ALL', - 'ANALYSE', - 'ANALYZE', - 'AND', - 'ANY', - 'ARRAY', - 'AS', - 'ASC', - 'ASYMMETRIC', - 'AUTHORIZATION', - 'BINARY', - 'BOTH', - 'CASE', - 'CAST', - 'CHECK', - 'COLLATE', - 'COLLATION', - 'COLUMN', - 'CONCURRENTLY', - 'CONSTRAINT', - 'CREATE', - 'CROSS', - 'CURRENT_CATALOG', - 'CURRENT_DATE', - 'CURRENT_ROLE', - 'CURRENT_SCHEMA', - 'CURRENT_TIME', - 'CURRENT_TIMESTAMP', - 'CURRENT_USER', - 'DEFAULT', - 'DEFERRABLE', - 'DESC', - 'DISTINCT', - 'DO', - 'ELSE', - 'END', - 'EXCEPT', - 'FALSE', - 'FETCH', - 'FOR', - 'FOREIGN', - 'FREEZE', - 'FROM', - 'FULL', - 'GRANT', - 'GROUP', - 'HAVING', - 'ILIKE', - 'IN', - 'INITIALLY', - 'INNER', - 'INTERSECT', - 'INTO', - 'IS', - 'ISNULL', - 'JOIN', - 'LATERAL', - 'LEADING', - 'LEFT', - 'LIKE', - 'LIMIT', - 'LOCALTIME', - 'LOCALTIMESTAMP', - 'NATURAL', - 'NOT', - 'NOTNULL', - 'NULL', - 'OFFSET', - 'ON', - 'ONLY', - 'OR', - 'ORDER', - 'OUTER', - 'OVERLAPS', - 'PLACING', - 'PRIMARY', - 'REFERENCES', - 'RETURNING', - 'RIGHT', - 'SELECT', - 'SESSION_USER', - 'SIMILAR', - 'SOME', - 'SYMMETRIC', - 'TABLE', - 'TABLESAMPLE', - 'THEN', - 'TO', - 'TRAILING', - 'TRUE', - 'UNION', - 'UNIQUE', - 'USER', - 'USING', - 'VARIADIC', - 'VERBOSE', - 'WHEN', - 'WHERE', - 'WINDOW', - 'WITH', - ], - operators: [ - 'AND', - 'BETWEEN', - 'IN', - 'LIKE', - 'NOT', - 'OR', - 'IS', - 'NULL', - 'INTERSECT', - 'UNION', - 'INNER', - 'JOIN', - 'LEFT', - 'OUTER', - 'RIGHT', - ], - builtinFunctions: [ - 'abbrev', - 'abs', - 'acldefault', - 'aclexplode', - 'acos', - 'acosd', - 'acosh', - 'age', - 'any', - 'area', - 'array_agg', - 'array_append', - 'array_cat', - 'array_dims', - 'array_fill', - 'array_length', - 'array_lower', - 'array_ndims', - 'array_position', - 'array_positions', - 'array_prepend', - 'array_remove', - 'array_replace', - 'array_to_json', - 'array_to_string', - 'array_to_tsvector', - 'array_upper', - 'ascii', - 'asin', - 'asind', - 'asinh', - 'atan', - 'atan2', - 'atan2d', - 'atand', - 'atanh', - 'avg', - 'bit', - 'bit_and', - 'bit_count', - 'bit_length', - 'bit_or', - 'bit_xor', - 'bool_and', - 'bool_or', - 'bound_box', - 'box', - 'brin_desummarize_range', - 'brin_summarize_new_values', - 'brin_summarize_range', - 'broadcast', - 'btrim', - 'cardinality', - 'cbrt', - 'ceil', - 'ceiling', - 'center', - 'char_length', - 'character_length', - 'chr', - 'circle', - 'clock_timestamp', - 'coalesce', - 'col_description', - 'concat', - 'concat_ws', - 'convert', - 'convert_from', - 'convert_to', - 'corr', - 'cos', - 'cosd', - 'cosh', - 'cot', - 'cotd', - 'count', - 'covar_pop', - 'covar_samp', - 'cume_dist', - 'current_catalog', - 'current_database', - 'current_date', - 'current_query', - 'current_role', - 'current_schema', - 'current_schemas', - 'current_setting', - 'current_time', - 'current_timestamp', - 'current_user', - 'currval', - 'cursor_to_xml', - 'cursor_to_xmlschema', - 'date_bin', - 'date_part', - 'date_trunc', - 'database_to_xml', - 'database_to_xml_and_xmlschema', - 'database_to_xmlschema', - 'decode', - 'degrees', - 'dense_rank', - 'diagonal', - 'diameter', - 'div', - 'encode', - 'enum_first', - 'enum_last', - 'enum_range', - 'every', - 'exp', - 'extract', - 'factorial', - 'family', - 'first_value', - 'floor', - 'format', - 'format_type', - 'gcd', - 'gen_random_uuid', - 'generate_series', - 'generate_subscripts', - 'get_bit', - 'get_byte', - 'get_current_ts_config', - 'gin_clean_pending_list', - 'greatest', - 'grouping', - 'has_any_column_privilege', - 'has_column_privilege', - 'has_database_privilege', - 'has_foreign_data_wrapper_privilege', - 'has_function_privilege', - 'has_language_privilege', - 'has_schema_privilege', - 'has_sequence_privilege', - 'has_server_privilege', - 'has_table_privilege', - 'has_tablespace_privilege', - 'has_type_privilege', - 'height', - 'host', - 'hostmask', - 'inet_client_addr', - 'inet_client_port', - 'inet_merge', - 'inet_same_family', - 'inet_server_addr', - 'inet_server_port', - 'initcap', - 'isclosed', - 'isempty', - 'isfinite', - 'isopen', - 'json_agg', - 'json_array_elements', - 'json_array_elements_text', - 'json_array_length', - 'json_build_array', - 'json_build_object', - 'json_each', - 'json_each_text', - 'json_extract_path', - 'json_extract_path_text', - 'json_object', - 'json_object_agg', - 'json_object_keys', - 'json_populate_record', - 'json_populate_recordset', - 'json_strip_nulls', - 'json_to_record', - 'json_to_recordset', - 'json_to_tsvector', - 'json_typeof', - 'jsonb_agg', - 'jsonb_array_elements', - 'jsonb_array_elements_text', - 'jsonb_array_length', - 'jsonb_build_array', - 'jsonb_build_object', - 'jsonb_each', - 'jsonb_each_text', - 'jsonb_extract_path', - 'jsonb_extract_path_text', - 'jsonb_insert', - 'jsonb_object', - 'jsonb_object_agg', - 'jsonb_object_keys', - 'jsonb_path_exists', - 'jsonb_path_match', - 'jsonb_path_query', - 'jsonb_path_query_array', - 'jsonb_path_exists_tz', - 'jsonb_path_query_first', - 'jsonb_path_query_array_tz', - 'jsonb_path_query_first_tz', - 'jsonb_path_query_tz', - 'jsonb_path_match_tz', - 'jsonb_populate_record', - 'jsonb_populate_recordset', - 'jsonb_pretty', - 'jsonb_set', - 'jsonb_set_lax', - 'jsonb_strip_nulls', - 'jsonb_to_record', - 'jsonb_to_recordset', - 'jsonb_to_tsvector', - 'jsonb_typeof', - 'justify_days', - 'justify_hours', - 'justify_interval', - 'lag', - 'last_value', - 'lastval', - 'lcm', - 'lead', - 'least', - 'left', - 'length', - 'line', - 'ln', - 'localtime', - 'localtimestamp', - 'log', - 'log10', - 'lower', - 'lower_inc', - 'lower_inf', - 'lpad', - 'lseg', - 'ltrim', - 'macaddr8_set7bit', - 'make_date', - 'make_interval', - 'make_time', - 'make_timestamp', - 'make_timestamptz', - 'makeaclitem', - 'masklen', - 'max', - 'md5', - 'min', - 'min_scale', - 'mod', - 'mode', - 'multirange', - 'netmask', - 'network', - 'nextval', - 'normalize', - 'now', - 'npoints', - 'nth_value', - 'ntile', - 'nullif', - 'num_nonnulls', - 'num_nulls', - 'numnode', - 'obj_description', - 'octet_length', - 'overlay', - 'parse_ident', - 'path', - 'pclose', - 'percent_rank', - 'percentile_cont', - 'percentile_disc', - 'pg_advisory_lock', - 'pg_advisory_lock_shared', - 'pg_advisory_unlock', - 'pg_advisory_unlock_all', - 'pg_advisory_unlock_shared', - 'pg_advisory_xact_lock', - 'pg_advisory_xact_lock_shared', - 'pg_backend_pid', - 'pg_backup_start_time', - 'pg_blocking_pids', - 'pg_cancel_backend', - 'pg_client_encoding', - 'pg_collation_actual_version', - 'pg_collation_is_visible', - 'pg_column_compression', - 'pg_column_size', - 'pg_conf_load_time', - 'pg_control_checkpoint', - 'pg_control_init', - 'pg_control_recovery', - 'pg_control_system', - 'pg_conversion_is_visible', - 'pg_copy_logical_replication_slot', - 'pg_copy_physical_replication_slot', - 'pg_create_logical_replication_slot', - 'pg_create_physical_replication_slot', - 'pg_create_restore_point', - 'pg_current_logfile', - 'pg_current_snapshot', - 'pg_current_wal_flush_lsn', - 'pg_current_wal_insert_lsn', - 'pg_current_wal_lsn', - 'pg_current_xact_id', - 'pg_current_xact_id_if_assigned', - 'pg_current_xlog_flush_location', - 'pg_current_xlog_insert_location', - 'pg_current_xlog_location', - 'pg_database_size', - 'pg_describe_object', - 'pg_drop_replication_slot', - 'pg_event_trigger_ddl_commands', - 'pg_event_trigger_dropped_objects', - 'pg_event_trigger_table_rewrite_oid', - 'pg_event_trigger_table_rewrite_reason', - 'pg_export_snapshot', - 'pg_filenode_relation', - 'pg_function_is_visible', - 'pg_get_catalog_foreign_keys', - 'pg_get_constraintdef', - 'pg_get_expr', - 'pg_get_function_arguments', - 'pg_get_function_identity_arguments', - 'pg_get_function_result', - 'pg_get_functiondef', - 'pg_get_indexdef', - 'pg_get_keywords', - 'pg_get_object_address', - 'pg_get_owned_sequence', - 'pg_get_ruledef', - 'pg_get_serial_sequence', - 'pg_get_statisticsobjdef', - 'pg_get_triggerdef', - 'pg_get_userbyid', - 'pg_get_viewdef', - 'pg_get_wal_replay_pause_state', - 'pg_has_role', - 'pg_identify_object', - 'pg_identify_object_as_address', - 'pg_import_system_collations', - 'pg_index_column_has_property', - 'pg_index_has_property', - 'pg_indexam_has_property', - 'pg_indexes_size', - 'pg_is_in_backup', - 'pg_is_in_recovery', - 'pg_is_other_temp_schema', - 'pg_is_wal_replay_paused', - 'pg_is_xlog_replay_paused', - 'pg_jit_available', - 'pg_last_committed_xact', - 'pg_last_wal_receive_lsn', - 'pg_last_wal_replay_lsn', - 'pg_last_xact_replay_timestamp', - 'pg_last_xlog_receive_location', - 'pg_last_xlog_replay_location', - 'pg_listening_channels', - 'pg_log_backend_memory_contexts', - 'pg_logical_emit_message', - 'pg_logical_slot_get_binary_changes', - 'pg_logical_slot_get_changes', - 'pg_logical_slot_peek_binary_changes', - 'pg_logical_slot_peek_changes', - 'pg_ls_archive_statusdir', - 'pg_ls_dir', - 'pg_ls_logdir', - 'pg_ls_tmpdir', - 'pg_ls_waldir', - 'pg_mcv_list_items', - 'pg_my_temp_schema', - 'pg_notification_queue_usage', - 'pg_opclass_is_visible', - 'pg_operator_is_visible', - 'pg_opfamily_is_visible', - 'pg_options_to_table', - 'pg_partition_ancestors', - 'pg_partition_root', - 'pg_partition_tree', - 'pg_postmaster_start_time', - 'pg_promote', - 'pg_read_binary_file', - 'pg_read_file', - 'pg_relation_filenode', - 'pg_relation_filepath', - 'pg_relation_size', - 'pg_reload_conf', - 'pg_replication_origin_advance', - 'pg_replication_origin_create', - 'pg_replication_origin_drop', - 'pg_replication_origin_oid', - 'pg_replication_origin_progress', - 'pg_replication_origin_session_is_setup', - 'pg_replication_origin_session_progress', - 'pg_replication_origin_session_reset', - 'pg_replication_origin_session_setup', - 'pg_replication_origin_xact_reset', - 'pg_replication_origin_xact_setup', - 'pg_replication_slot_advance', - 'pg_rotate_logfile', - 'pg_safe_snapshot_blocking_pids', - 'pg_size_bytes', - 'pg_size_pretty', - 'pg_sleep', - 'pg_sleep_for', - 'pg_sleep_until', - 'pg_snapshot_xip', - 'pg_snapshot_xmax', - 'pg_snapshot_xmin', - 'pg_start_backup', - 'pg_stat_file', - 'pg_statistics_obj_is_visible', - 'pg_stop_backup', - 'pg_switch_wal', - 'pg_switch_xlog', - 'pg_table_is_visible', - 'pg_table_size', - 'pg_tablespace_databases', - 'pg_tablespace_location', - 'pg_tablespace_size', - 'pg_terminate_backend', - 'pg_total_relation_size', - 'pg_trigger_depth', - 'pg_try_advisory_lock', - 'pg_try_advisory_lock_shared', - 'pg_try_advisory_xact_lock', - 'pg_try_advisory_xact_lock_shared', - 'pg_ts_config_is_visible', - 'pg_ts_dict_is_visible', - 'pg_ts_parser_is_visible', - 'pg_ts_template_is_visible', - 'pg_type_is_visible', - 'pg_typeof', - 'pg_visible_in_snapshot', - 'pg_wal_lsn_diff', - 'pg_wal_replay_pause', - 'pg_wal_replay_resume', - 'pg_walfile_name', - 'pg_walfile_name_offset', - 'pg_xact_commit_timestamp', - 'pg_xact_commit_timestamp_origin', - 'pg_xact_status', - 'pg_xlog_location_diff', - 'pg_xlog_replay_pause', - 'pg_xlog_replay_resume', - 'pg_xlogfile_name', - 'pg_xlogfile_name_offset', - 'phraseto_tsquery', - 'pi', - 'plainto_tsquery', - 'point', - 'polygon', - 'popen', - 'position', - 'power', - 'pqserverversion', - 'query_to_xml', - 'query_to_xml_and_xmlschema', - 'query_to_xmlschema', - 'querytree', - 'quote_ident', - 'quote_literal', - 'quote_nullable', - 'radians', - 'radius', - 'random', - 'range_agg', - 'range_intersect_agg', - 'range_merge', - 'rank', - 'regexp_count', - 'regexp_instr', - 'regexp_like', - 'regexp_match', - 'regexp_matches', - 'regexp_replace', - 'regexp_split_to_array', - 'regexp_split_to_table', - 'regexp_substr', - 'regr_avgx', - 'regr_avgy', - 'regr_count', - 'regr_intercept', - 'regr_r2', - 'regr_slope', - 'regr_sxx', - 'regr_sxy', - 'regr_syy', - 'repeat', - 'replace', - 'reverse', - 'right', - 'round', - 'row_number', - 'row_security_active', - 'row_to_json', - 'rpad', - 'rtrim', - 'scale', - 'schema_to_xml', - 'schema_to_xml_and_xmlschema', - 'schema_to_xmlschema', - 'session_user', - 'set_bit', - 'set_byte', - 'set_config', - 'set_masklen', - 'setseed', - 'setval', - 'setweight', - 'sha224', - 'sha256', - 'sha384', - 'sha512', - 'shobj_description', - 'sign', - 'sin', - 'sind', - 'sinh', - 'slope', - 'split_part', - 'sprintf', - 'sqrt', - 'starts_with', - 'statement_timestamp', - 'stddev', - 'stddev_pop', - 'stddev_samp', - 'string_agg', - 'string_to_array', - 'string_to_table', - 'strip', - 'strpos', - 'substr', - 'substring', - 'sum', - 'suppress_redundant_updates_trigger', - 'table_to_xml', - 'table_to_xml_and_xmlschema', - 'table_to_xmlschema', - 'tan', - 'tand', - 'tanh', - 'text', - 'timeofday', - 'timezone', - 'to_ascii', - 'to_char', - 'to_date', - 'to_hex', - 'to_json', - 'to_number', - 'to_regclass', - 'to_regcollation', - 'to_regnamespace', - 'to_regoper', - 'to_regoperator', - 'to_regproc', - 'to_regprocedure', - 'to_regrole', - 'to_regtype', - 'to_timestamp', - 'to_tsquery', - 'to_tsvector', - 'transaction_timestamp', - 'translate', - 'trim', - 'trim_array', - 'trim_scale', - 'trunc', - 'ts_debug', - 'ts_delete', - 'ts_filter', - 'ts_headline', - 'ts_lexize', - 'ts_parse', - 'ts_rank', - 'ts_rank_cd', - 'ts_rewrite', - 'ts_stat', - 'ts_token_type', - 'tsquery_phrase', - 'tsvector_to_array', - 'tsvector_update_trigger', - 'tsvector_update_trigger_column', - 'txid_current', - 'txid_current_if_assigned', - 'txid_current_snapshot', - 'txid_snapshot_xip', - 'txid_snapshot_xmax', - 'txid_snapshot_xmin', - 'txid_status', - 'txid_visible_in_snapshot', - 'unistr', - 'unnest', - 'upper', - 'upper_inc', - 'upper_inf', - 'user', - 'var_pop', - 'var_samp', - 'variance', - 'version', - 'websearch_to_tsquery', - 'width', - 'width_bucket', - 'xml_is_well_formed', - 'xml_is_well_formed_content', - 'xml_is_well_formed_document', - 'xmlagg', - 'xmlcomment', - 'xmlconcat', - 'xmlelement', - 'xmlexists', - 'xmlforest', - 'xmlparse', - 'xmlpi', - 'xmlroot', - 'xmlserialize', - 'xpath', - 'xpath_exists', - ], - builtinVariables: [], - pseudoColumns: [], - tokenizer: { - root: [ - { include: '@comments' }, - { include: '@whitespace' }, - { include: '@pseudoColumns' }, - { include: '@numbers' }, - { include: '@strings' }, - { include: '@complexIdentifiers' }, - { include: '@scopes' }, - [/[;,.]/, 'delimiter'], - [/[()]/, '@brackets'], - [ - /[\w@#$]+/, - { - cases: { - '@operators': 'operator', - '@builtinVariables': 'predefined', - '@builtinFunctions': 'predefined', - '@keywords': 'keyword', - '@default': 'identifier', - }, - }, - ], - [/[<>=!%&+\-*/|~^]/, 'operator'], - ], - whitespace: [[/\s+/, 'white']], - comments: [ - [/--+.*/, 'comment'], - [/\/\*/, { token: 'comment.quote', next: '@comment' }], - ], - comment: [ - [/[^*/]+/, 'comment'], - [/\*\//, { token: 'comment.quote', next: '@pop' }], - [/./, 'comment'], - ], - pseudoColumns: [ - [ - /[$][A-Za-z_][\w@#$]*/, - { cases: { '@pseudoColumns': 'predefined', '@default': 'identifier' } }, - ], - ], - numbers: [ - [/0[xX][0-9a-fA-F]*/, 'number'], - [/[$][+-]*\d*(\.\d*)?/, 'number'], - [/((\d+(\.\d*)?)|(\.\d+))([eE][\-+]?\d+)?/, 'number'], - ], - strings: [[/'/, { token: 'string', next: '@string' }]], - string: [ - [/[^']+/, 'string'], - [/''/, 'string'], - [/'/, { token: 'string', next: '@pop' }], - ], - complexIdentifiers: [[/"/, { token: 'identifier.quote', next: '@quotedIdentifier' }]], - quotedIdentifier: [ - [/[^"]+/, 'identifier'], - [/""/, 'identifier'], - [/"/, { token: 'identifier.quote', next: '@pop' }], - ], - scopes: [], - }, - } - return g(m) - })() - return moduleExports -}) diff --git a/apps/studio/public/monaco-editor/editor/editor.main.css b/apps/studio/public/monaco-editor/editor/editor.main.css deleted file mode 100644 index 527f6c30200..00000000000 --- a/apps/studio/public/monaco-editor/editor/editor.main.css +++ /dev/null @@ -1,6 +0,0 @@ -/*!----------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Version: 0.37.0(20c3f8ff1e9176c954b34c052b2a84358d41a030) - * Released under the MIT license - * https://github.com/microsoft/vscode/blob/main/LICENSE.txt - *-----------------------------------------------------------*/.monaco-action-bar{white-space:nowrap;height:100%}.monaco-action-bar .actions-container{display:flex;margin:0 auto;padding:0;height:100%;width:100%;align-items:center}.monaco-action-bar.vertical .actions-container{display:inline-block}.monaco-action-bar .action-item{display:block;align-items:center;justify-content:center;cursor:pointer;position:relative}.monaco-action-bar .action-item.disabled{cursor:default}.monaco-action-bar .action-item .codicon,.monaco-action-bar .action-item .icon{display:block}.monaco-action-bar .action-item .codicon{display:flex;align-items:center;width:16px;height:16px}.monaco-action-bar .action-label{display:flex;font-size:11px;padding:3px;border-radius:5px}.monaco-action-bar .action-item.disabled .action-label,.monaco-action-bar .action-item.disabled .action-label:before,.monaco-action-bar .action-item.disabled .action-label:hover{opacity:.6}.monaco-action-bar.vertical{text-align:left}.monaco-action-bar.vertical .action-item{display:block}.monaco-action-bar.vertical .action-label.separator{display:block;border-bottom:1px solid #bbb;padding-top:1px;margin-left:.8em;margin-right:.8em}.monaco-action-bar .action-item .action-label.separator{width:1px;height:16px;margin:5px 4px!important;cursor:default;min-width:1px;padding:0;background-color:#bbb}.secondary-actions .monaco-action-bar .action-label{margin-left:6px}.monaco-action-bar .action-item.select-container{overflow:hidden;flex:1;max-width:170px;min-width:60px;display:flex;align-items:center;justify-content:center;margin-right:10px}.monaco-action-bar .action-item.action-dropdown-item{display:flex}.monaco-action-bar .action-item.action-dropdown-item>.action-dropdown-item-separator{display:flex;align-items:center;cursor:default}.monaco-action-bar .action-item.action-dropdown-item>.action-dropdown-item-separator>div{width:1px}.monaco-aria-container{position:absolute;left:-999em}.monaco-text-button{box-sizing:border-box;display:flex;width:100%;padding:4px;border-radius:2px;text-align:center;cursor:pointer;justify-content:center;align-items:center;border:1px solid var(--vscode-button-border,transparent);line-height:18px}.monaco-text-button:focus{outline-offset:2px!important}.monaco-text-button:hover{text-decoration:none!important}.monaco-button.disabled,.monaco-button.disabled:focus{opacity:.4!important;cursor:default}.monaco-text-button .codicon{margin:0 .2em;color:inherit!important}.monaco-text-button.monaco-text-button-with-short-label{flex-direction:row;flex-wrap:wrap;padding:0 4px;overflow:hidden;height:28px}.monaco-text-button.monaco-text-button-with-short-label>.monaco-button-label{flex-basis:100%}.monaco-text-button.monaco-text-button-with-short-label>.monaco-button-label-short{flex-grow:1;width:0;overflow:hidden}.monaco-text-button.monaco-text-button-with-short-label>.monaco-button-label,.monaco-text-button.monaco-text-button-with-short-label>.monaco-button-label-short{display:flex;justify-content:center;align-items:center;font-weight:400;font-style:inherit;padding:4px 0}.monaco-button-dropdown{display:flex;cursor:pointer}.monaco-button-dropdown.disabled{cursor:default}.monaco-button-dropdown>.monaco-button:focus{outline-offset:-1px!important}.monaco-button-dropdown.disabled>.monaco-button-dropdown-separator,.monaco-button-dropdown.disabled>.monaco-button.disabled,.monaco-button-dropdown.disabled>.monaco-button.disabled:focus{opacity:.4!important}.monaco-button-dropdown>.monaco-button.monaco-text-button{border-right-width:0!important}.monaco-button-dropdown .monaco-button-dropdown-separator{padding:4px 0;cursor:default}.monaco-button-dropdown .monaco-button-dropdown-separator>div{height:100%;width:1px}.monaco-button-dropdown>.monaco-button.monaco-dropdown-button{border:1px solid var(--vscode-button-border,transparent);border-left-width:0!important;border-radius:0 2px 2px 0}.monaco-button-dropdown>.monaco-button.monaco-text-button{border-radius:2px 0 0 2px}.monaco-description-button{display:flex;flex-direction:column;align-items:center;margin:4px 5px}.monaco-description-button .monaco-button-description{font-style:italic;font-size:11px;padding:4px 20px}.monaco-description-button .monaco-button-description,.monaco-description-button .monaco-button-label{display:flex;justify-content:center;align-items:center}.monaco-description-button .monaco-button-description>.codicon,.monaco-description-button .monaco-button-label>.codicon{margin:0 .2em;color:inherit!important}@font-face{font-family:codicon;font-display:block;src:url(../base/browser/ui/codicons/codicon/codicon.ttf) format("truetype")}.codicon[class*=codicon-]{font:normal normal normal 16px/1 codicon;display:inline-block;text-decoration:none;text-rendering:auto;text-align:center;text-transform:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;user-select:none;-webkit-user-select:none}.codicon-wrench-subaction{opacity:.5}@keyframes codicon-spin{to{transform:rotate(1turn)}}.codicon-gear.codicon-modifier-spin,.codicon-loading.codicon-modifier-spin,.codicon-notebook-state-executing.codicon-modifier-spin,.codicon-sync.codicon-modifier-spin{animation:codicon-spin 1.5s steps(30) infinite}.codicon-modifier-disabled{opacity:.4}.codicon-loading,.codicon-tree-item-loading:before{animation-duration:1s!important;animation-timing-function:cubic-bezier(.53,.21,.29,.67)!important}.context-view{position:absolute}.context-view.fixed{all:initial;font-family:inherit;font-size:13px;position:fixed;color:inherit}.monaco-count-badge{padding:3px 6px;border-radius:11px;font-size:11px;min-width:18px;min-height:18px;line-height:11px;font-weight:400;text-align:center;display:inline-block;box-sizing:border-box}.monaco-count-badge.long{padding:2px 3px;border-radius:2px;min-height:auto;line-height:normal}.monaco-dropdown{height:100%;padding:0}.monaco-dropdown>.dropdown-label{cursor:pointer;height:100%;display:flex;align-items:center;justify-content:center}.monaco-dropdown>.dropdown-label>.action-label.disabled{cursor:default}.monaco-dropdown-with-primary{display:flex!important;flex-direction:row;border-radius:5px}.monaco-dropdown-with-primary>.action-container>.action-label{margin-right:0}.monaco-dropdown-with-primary>.dropdown-action-container>.monaco-dropdown>.dropdown-label .codicon[class*=codicon-]{font-size:12px;padding-left:0;padding-right:0;line-height:16px;margin-left:-3px}.monaco-dropdown-with-primary>.dropdown-action-container>.monaco-dropdown>.dropdown-label>.action-label{display:block;background-size:16px;background-position:50%;background-repeat:no-repeat}.monaco-findInput{position:relative}.monaco-findInput .monaco-inputbox{font-size:13px;width:100%}.monaco-findInput>.controls{position:absolute;top:3px;right:2px}.vs .monaco-findInput.disabled{background-color:#e1e1e1}.vs-dark .monaco-findInput.disabled{background-color:#333}.hc-light .monaco-findInput.highlight-0 .controls,.monaco-findInput.highlight-0 .controls{animation:monaco-findInput-highlight-0 .1s linear 0s}.hc-light .monaco-findInput.highlight-1 .controls,.monaco-findInput.highlight-1 .controls{animation:monaco-findInput-highlight-1 .1s linear 0s}.hc-black .monaco-findInput.highlight-0 .controls,.vs-dark .monaco-findInput.highlight-0 .controls{animation:monaco-findInput-highlight-dark-0 .1s linear 0s}.hc-black .monaco-findInput.highlight-1 .controls,.vs-dark .monaco-findInput.highlight-1 .controls{animation:monaco-findInput-highlight-dark-1 .1s linear 0s}@keyframes monaco-findInput-highlight-0{0%{background:rgba(253,255,0,.8)}to{background:transparent}}@keyframes monaco-findInput-highlight-1{0%{background:rgba(253,255,0,.8)}99%{background:transparent}}@keyframes monaco-findInput-highlight-dark-0{0%{background:hsla(0,0%,100%,.44)}to{background:transparent}}@keyframes monaco-findInput-highlight-dark-1{0%{background:hsla(0,0%,100%,.44)}99%{background:transparent}}.monaco-hover{cursor:default;position:absolute;overflow:hidden;z-index:50;user-select:text;-webkit-user-select:text;box-sizing:initial;animation:fadein .1s linear;line-height:1.5em}.monaco-hover.hidden{display:none}.monaco-hover a:hover:not(.disabled){cursor:pointer}.monaco-hover .hover-contents:not(.html-hover-contents){padding:4px 8px}.monaco-hover .markdown-hover>.hover-contents:not(.code-hover-contents){max-width:500px;word-wrap:break-word}.monaco-hover .markdown-hover>.hover-contents:not(.code-hover-contents) hr{min-width:100%}.monaco-hover .code,.monaco-hover h1,.monaco-hover h2,.monaco-hover h3,.monaco-hover h4,.monaco-hover h5,.monaco-hover h6,.monaco-hover p,.monaco-hover ul{margin:8px 0}.monaco-hover h1,.monaco-hover h2,.monaco-hover h3,.monaco-hover h4,.monaco-hover h5,.monaco-hover h6{line-height:1.1}.monaco-hover code{font-family:var(--monaco-monospace-font)}.monaco-hover hr{box-sizing:border-box;border-left:0;border-right:0;margin:4px -8px -4px;height:1px}.monaco-hover .code:first-child,.monaco-hover p:first-child,.monaco-hover ul:first-child{margin-top:0}.monaco-hover .code:last-child,.monaco-hover p:last-child,.monaco-hover ul:last-child{margin-bottom:0}.monaco-hover ol,.monaco-hover ul{padding-left:20px}.monaco-hover li>p{margin-bottom:0}.monaco-hover li>ul{margin-top:0}.monaco-hover code{border-radius:3px;padding:0 .4em}.monaco-hover .monaco-tokenized-source{white-space:pre-wrap}.monaco-hover .hover-row.status-bar{font-size:12px;line-height:22px}.monaco-hover .hover-row.status-bar .info{font-style:italic;padding:0 8px}.monaco-hover .hover-row.status-bar .actions{display:flex;padding:0 8px}.monaco-hover .hover-row.status-bar .actions .action-container{margin-right:16px;cursor:pointer}.monaco-hover .hover-row.status-bar .actions .action-container .action .icon{padding-right:4px}.monaco-hover .markdown-hover .hover-contents .codicon{color:inherit;font-size:inherit;vertical-align:middle}.monaco-hover .hover-contents a.code-link,.monaco-hover .hover-contents a.code-link:hover{color:inherit}.monaco-hover .hover-contents a.code-link:before{content:"("}.monaco-hover .hover-contents a.code-link:after{content:")"}.monaco-hover .hover-contents a.code-link>span{text-decoration:underline;border-bottom:1px solid transparent;text-underline-position:under;color:var(--vscode-textLink-foreground)}.monaco-hover .hover-contents a.code-link>span:hover{color:var(--vscode-textLink-activeForeground)}.monaco-hover .markdown-hover .hover-contents:not(.code-hover-contents):not(.html-hover-contents) span{margin-bottom:4px;display:inline-block}.monaco-hover-content .action-container a{-webkit-user-select:none;user-select:none}.monaco-hover-content .action-container.disabled{pointer-events:none;opacity:.4;cursor:default}.monaco-icon-label{display:flex;overflow:hidden;text-overflow:ellipsis}.monaco-icon-label:before{background-size:16px;background-position:0;background-repeat:no-repeat;padding-right:6px;width:16px;height:22px;line-height:inherit!important;display:inline-block;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;vertical-align:top;flex-shrink:0}.monaco-icon-label-container.disabled{color:var(--vscode-disabledForeground)}.monaco-icon-label>.monaco-icon-label-container{min-width:0;overflow:hidden;text-overflow:ellipsis;flex:1}.monaco-icon-label>.monaco-icon-label-container>.monaco-icon-name-container>.label-name{color:inherit;white-space:pre}.monaco-icon-label>.monaco-icon-label-container>.monaco-icon-name-container>.label-name>.label-separator{margin:0 2px;opacity:.5}.monaco-icon-label>.monaco-icon-label-container>.monaco-icon-description-container>.label-description{opacity:.7;margin-left:.5em;font-size:.9em;white-space:pre}.monaco-icon-label.nowrap>.monaco-icon-label-container>.monaco-icon-description-container>.label-description{white-space:nowrap}.vs .monaco-icon-label>.monaco-icon-label-container>.monaco-icon-description-container>.label-description{opacity:.95}.monaco-icon-label.italic>.monaco-icon-label-container>.monaco-icon-description-container>.label-description,.monaco-icon-label.italic>.monaco-icon-label-container>.monaco-icon-name-container>.label-name{font-style:italic}.monaco-icon-label.deprecated{text-decoration:line-through;opacity:.66}.monaco-icon-label.italic:after{font-style:italic}.monaco-icon-label.strikethrough>.monaco-icon-label-container>.monaco-icon-description-container>.label-description,.monaco-icon-label.strikethrough>.monaco-icon-label-container>.monaco-icon-name-container>.label-name{text-decoration:line-through}.monaco-icon-label:after{opacity:.75;font-size:90%;font-weight:600;margin:auto 16px 0 5px;text-align:center}.monaco-list:focus .selected .monaco-icon-label,.monaco-list:focus .selected .monaco-icon-label:after{color:inherit!important}.monaco-list-row.focused.selected .label-description,.monaco-list-row.selected .label-description{opacity:.8}.monaco-inputbox{position:relative;display:block;padding:0;box-sizing:border-box;border-radius:2px;font-size:inherit}.monaco-inputbox>.ibwrapper>.input,.monaco-inputbox>.ibwrapper>.mirror{padding:4px 6px}.monaco-inputbox>.ibwrapper{position:relative;width:100%;height:100%}.monaco-inputbox>.ibwrapper>.input{display:inline-block;box-sizing:border-box;width:100%;height:100%;line-height:inherit;border:none;font-family:inherit;font-size:inherit;resize:none;color:inherit}.monaco-inputbox>.ibwrapper>input{text-overflow:ellipsis}.monaco-inputbox>.ibwrapper>textarea.input{display:block;scrollbar-width:none;outline:none}.monaco-inputbox>.ibwrapper>textarea.input::-webkit-scrollbar{display:none}.monaco-inputbox>.ibwrapper>textarea.input.empty{white-space:nowrap}.monaco-inputbox>.ibwrapper>.mirror{position:absolute;display:inline-block;width:100%;top:0;left:0;box-sizing:border-box;white-space:pre-wrap;visibility:hidden;word-wrap:break-word}.monaco-inputbox-container{text-align:right}.monaco-inputbox-container .monaco-inputbox-message{display:inline-block;overflow:hidden;text-align:left;width:100%;box-sizing:border-box;padding:.4em;font-size:12px;line-height:17px;margin-top:-1px;word-wrap:break-word}.monaco-inputbox .monaco-action-bar{position:absolute;right:2px;top:4px}.monaco-inputbox .monaco-action-bar .action-item{margin-left:2px}.monaco-inputbox .monaco-action-bar .action-item .codicon{background-repeat:no-repeat;width:16px;height:16px}.monaco-keybinding{display:flex;align-items:center;line-height:10px}.monaco-keybinding>.monaco-keybinding-key{display:inline-block;border-style:solid;border-width:1px;border-radius:3px;vertical-align:middle;font-size:11px;padding:3px 5px;margin:0 2px}.monaco-keybinding>.monaco-keybinding-key:first-child{margin-left:0}.monaco-keybinding>.monaco-keybinding-key:last-child{margin-right:0}.monaco-keybinding>.monaco-keybinding-key-separator{display:inline-block}.monaco-keybinding>.monaco-keybinding-key-chord-separator{width:6px}.monaco-list{position:relative;height:100%;width:100%;white-space:nowrap}.monaco-list.mouse-support{user-select:none;-webkit-user-select:none}.monaco-list>.monaco-scrollable-element{height:100%}.monaco-list-rows{position:relative;width:100%;height:100%}.monaco-list.horizontal-scrolling .monaco-list-rows{width:auto;min-width:100%}.monaco-list-row{position:absolute;box-sizing:border-box;overflow:hidden;width:100%}.monaco-list.mouse-support .monaco-list-row{cursor:pointer;touch-action:none}.monaco-list-row.scrolling{display:none!important}.monaco-list.element-focused,.monaco-list.selection-multiple,.monaco-list.selection-single{outline:0!important}.monaco-drag-image{display:inline-block;padding:1px 7px;border-radius:10px;font-size:12px;position:absolute;z-index:1000}.monaco-list-type-filter-message{position:absolute;box-sizing:border-box;width:100%;height:100%;top:0;left:0;padding:40px 1em 1em;text-align:center;white-space:normal;opacity:.7;pointer-events:none}.monaco-list-type-filter-message:empty{display:none}.monaco-mouse-cursor-text{cursor:text}.monaco-progress-container{width:100%;height:5px;overflow:hidden}.monaco-progress-container .progress-bit{width:2%;height:5px;position:absolute;left:0;display:none}.monaco-progress-container.active .progress-bit{display:inherit}.monaco-progress-container.discrete .progress-bit{left:0;transition:width .1s linear}.monaco-progress-container.discrete.done .progress-bit{width:100%}.monaco-progress-container.infinite .progress-bit{animation-name:progress;animation-duration:4s;animation-iteration-count:infinite;transform:translateZ(0);animation-timing-function:linear}.monaco-progress-container.infinite.infinite-long-running .progress-bit{animation-timing-function:steps(100)}@keyframes progress{0%{transform:translateX(0) scaleX(1)}50%{transform:translateX(2500%) scaleX(3)}to{transform:translateX(4900%) scaleX(1)}}:root{--vscode-sash-size:4px}.monaco-sash{position:absolute;z-index:35;touch-action:none}.monaco-sash.disabled{pointer-events:none}.monaco-sash.mac.vertical{cursor:col-resize}.monaco-sash.vertical.minimum{cursor:e-resize}.monaco-sash.vertical.maximum{cursor:w-resize}.monaco-sash.mac.horizontal{cursor:row-resize}.monaco-sash.horizontal.minimum{cursor:s-resize}.monaco-sash.horizontal.maximum{cursor:n-resize}.monaco-sash.disabled{cursor:default!important;pointer-events:none!important}.monaco-sash.vertical{cursor:ew-resize;top:0;width:var(--vscode-sash-size);height:100%}.monaco-sash.horizontal{cursor:ns-resize;left:0;width:100%;height:var(--vscode-sash-size)}.monaco-sash:not(.disabled)>.orthogonal-drag-handle{content:" ";height:calc(var(--vscode-sash-size)*2);width:calc(var(--vscode-sash-size)*2);z-index:100;display:block;cursor:all-scroll;position:absolute}.monaco-sash.horizontal.orthogonal-edge-north:not(.disabled)>.orthogonal-drag-handle.start,.monaco-sash.horizontal.orthogonal-edge-south:not(.disabled)>.orthogonal-drag-handle.end{cursor:nwse-resize}.monaco-sash.horizontal.orthogonal-edge-north:not(.disabled)>.orthogonal-drag-handle.end,.monaco-sash.horizontal.orthogonal-edge-south:not(.disabled)>.orthogonal-drag-handle.start{cursor:nesw-resize}.monaco-sash.vertical>.orthogonal-drag-handle.start{left:calc(var(--vscode-sash-size)*-0.5);top:calc(var(--vscode-sash-size)*-1)}.monaco-sash.vertical>.orthogonal-drag-handle.end{left:calc(var(--vscode-sash-size)*-0.5);bottom:calc(var(--vscode-sash-size)*-1)}.monaco-sash.horizontal>.orthogonal-drag-handle.start{top:calc(var(--vscode-sash-size)*-0.5);left:calc(var(--vscode-sash-size)*-1)}.monaco-sash.horizontal>.orthogonal-drag-handle.end{top:calc(var(--vscode-sash-size)*-0.5);right:calc(var(--vscode-sash-size)*-1)}.monaco-sash:before{content:"";pointer-events:none;position:absolute;width:100%;height:100%;background:transparent}.monaco-workbench:not(.reduce-motion) .monaco-sash:before{transition:background-color .1s ease-out}.monaco-sash.active:before,.monaco-sash.hover:before{background:var(--vscode-sash-hoverBorder)}.monaco-sash.vertical:before{width:var(--vscode-sash-hover-size);left:calc(50% - var(--vscode-sash-hover-size)/2)}.monaco-sash.horizontal:before{height:var(--vscode-sash-hover-size);top:calc(50% - var(--vscode-sash-hover-size)/2)}.pointer-events-disabled{pointer-events:none!important}.monaco-sash.debug{background:#0ff}.monaco-sash.debug.disabled{background:rgba(0,255,255,.2)}.monaco-sash.debug:not(.disabled)>.orthogonal-drag-handle{background:red}.monaco-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.monaco-scrollable-element>.visible{opacity:1;background:transparent;transition:opacity .1s linear;z-index:11}.monaco-scrollable-element>.invisible{opacity:0;pointer-events:none}.monaco-scrollable-element>.invisible.fade{transition:opacity .8s linear}.monaco-scrollable-element>.shadow{position:absolute;display:none}.monaco-scrollable-element>.shadow.top{display:block;top:0;left:3px;height:3px;width:100%;box-shadow:var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset}.monaco-scrollable-element>.shadow.left{display:block;top:3px;left:0;height:100%;width:3px;box-shadow:var(--vscode-scrollbar-shadow) 6px 0 6px -6px inset}.monaco-scrollable-element>.shadow.top-left-corner{display:block;top:0;left:0;height:3px;width:3px}.monaco-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow) 6px 0 6px -6px inset}.monaco-scrollable-element>.scrollbar>.slider{background:var(--vscode-scrollbarSlider-background)}.monaco-scrollable-element>.scrollbar>.slider:hover{background:var(--vscode-scrollbarSlider-hoverBackground)}.monaco-scrollable-element>.scrollbar>.slider.active{background:var(--vscode-scrollbarSlider-activeBackground)}.monaco-select-box{width:100%;cursor:pointer;border-radius:2px}.monaco-select-box-dropdown-container{font-size:13px;font-weight:400;text-transform:none}.monaco-action-bar .action-item.select-container{cursor:default}.monaco-action-bar .action-item .monaco-select-box{cursor:pointer;min-width:100px;min-height:18px;padding:2px 23px 2px 8px}.mac .monaco-action-bar .action-item .monaco-select-box{font-size:11px;border-radius:5px}.monaco-select-box-dropdown-padding{--dropdown-padding-top:1px;--dropdown-padding-bottom:1px}.hc-black .monaco-select-box-dropdown-padding,.hc-light .monaco-select-box-dropdown-padding{--dropdown-padding-top:3px;--dropdown-padding-bottom:4px}.monaco-select-box-dropdown-container{display:none;box-sizing:border-box}.monaco-select-box-dropdown-container>.select-box-details-pane>.select-box-description-markdown *{margin:0}.monaco-select-box-dropdown-container>.select-box-details-pane>.select-box-description-markdown a:focus{outline:1px solid -webkit-focus-ring-color;outline-offset:-1px}.monaco-select-box-dropdown-container>.select-box-details-pane>.select-box-description-markdown code{line-height:15px;font-family:var(--monaco-monospace-font)}.monaco-select-box-dropdown-container.visible{display:flex;flex-direction:column;text-align:left;width:1px;overflow:hidden;border-bottom-left-radius:3px;border-bottom-right-radius:3px}.monaco-select-box-dropdown-container>.select-box-dropdown-list-container{flex:0 0 auto;align-self:flex-start;padding-top:var(--dropdown-padding-top);padding-bottom:var(--dropdown-padding-bottom);padding-left:1px;padding-right:1px;width:100%;overflow:hidden;box-sizing:border-box}.monaco-select-box-dropdown-container>.select-box-details-pane{padding:5px}.hc-black .monaco-select-box-dropdown-container>.select-box-dropdown-list-container{padding-top:var(--dropdown-padding-top);padding-bottom:var(--dropdown-padding-bottom)}.monaco-select-box-dropdown-container>.select-box-dropdown-list-container .monaco-list .monaco-list-row{cursor:pointer}.monaco-select-box-dropdown-container>.select-box-dropdown-list-container .monaco-list .monaco-list-row>.option-text{text-overflow:ellipsis;overflow:hidden;padding-left:3.5px;white-space:nowrap;float:left}.monaco-select-box-dropdown-container>.select-box-dropdown-list-container .monaco-list .monaco-list-row>.option-detail{text-overflow:ellipsis;overflow:hidden;padding-left:3.5px;white-space:nowrap;float:left;opacity:.7}.monaco-select-box-dropdown-container>.select-box-dropdown-list-container .monaco-list .monaco-list-row>.option-decorator-right{text-overflow:ellipsis;overflow:hidden;padding-right:10px;white-space:nowrap;float:right}.monaco-select-box-dropdown-container>.select-box-dropdown-list-container .monaco-list .monaco-list-row>.visually-hidden{position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden}.monaco-select-box-dropdown-container>.select-box-dropdown-container-width-control{flex:1 1 auto;align-self:flex-start;opacity:0}.monaco-select-box-dropdown-container>.select-box-dropdown-container-width-control>.width-control-div{overflow:hidden;max-height:0}.monaco-select-box-dropdown-container>.select-box-dropdown-container-width-control>.width-control-div>.option-text-width-control{padding-left:4px;padding-right:8px;white-space:nowrap}.monaco-split-view2{position:relative;width:100%;height:100%}.monaco-split-view2>.sash-container{position:absolute;width:100%;height:100%;pointer-events:none}.monaco-split-view2>.sash-container>.monaco-sash{pointer-events:auto}.monaco-split-view2>.monaco-scrollable-element{width:100%;height:100%}.monaco-split-view2>.monaco-scrollable-element>.split-view-container{width:100%;height:100%;white-space:nowrap;position:relative}.monaco-split-view2>.monaco-scrollable-element>.split-view-container>.split-view-view{white-space:normal;position:absolute}.monaco-split-view2>.monaco-scrollable-element>.split-view-container>.split-view-view:not(.visible){display:none}.monaco-split-view2.vertical>.monaco-scrollable-element>.split-view-container>.split-view-view{width:100%}.monaco-split-view2.horizontal>.monaco-scrollable-element>.split-view-container>.split-view-view{height:100%}.monaco-split-view2.separator-border>.monaco-scrollable-element>.split-view-container>.split-view-view:not(:first-child):before{content:" ";position:absolute;top:0;left:0;z-index:5;pointer-events:none;background-color:var(--separator-border)}.monaco-split-view2.separator-border.horizontal>.monaco-scrollable-element>.split-view-container>.split-view-view:not(:first-child):before{height:100%;width:1px}.monaco-split-view2.separator-border.vertical>.monaco-scrollable-element>.split-view-container>.split-view-view:not(:first-child):before{height:1px;width:100%}.monaco-table{display:flex;flex-direction:column;position:relative;height:100%;width:100%;white-space:nowrap;overflow:hidden}.monaco-table>.monaco-split-view2{border-bottom:1px solid transparent}.monaco-table>.monaco-list{flex:1}.monaco-table-tr{display:flex;height:100%}.monaco-table-th{width:100%;height:100%;font-weight:700;overflow:hidden;text-overflow:ellipsis}.monaco-table-td,.monaco-table-th{box-sizing:border-box;flex-shrink:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.monaco-table>.monaco-split-view2 .monaco-sash.vertical:before{content:"";position:absolute;left:calc(var(--vscode-sash-size)/2);width:0;border-left:1px solid transparent}.monaco-workbench:not(.reduce-motion) .monaco-table>.monaco-split-view2,.monaco-workbench:not(.reduce-motion) .monaco-table>.monaco-split-view2 .monaco-sash.vertical:before{transition:border-color .2s ease-out}.monaco-custom-toggle{margin-left:2px;float:left;cursor:pointer;overflow:hidden;width:20px;height:20px;border-radius:3px;border:1px solid transparent;padding:1px;box-sizing:border-box;user-select:none;-webkit-user-select:none}.monaco-custom-toggle:hover{background-color:var(--vscode-inputOption-hoverBackground)}.hc-black .monaco-custom-toggle:hover,.hc-light .monaco-custom-toggle:hover{border:1px dashed var(--vscode-focusBorder)}.hc-black .monaco-custom-toggle,.hc-black .monaco-custom-toggle:hover,.hc-light .monaco-custom-toggle,.hc-light .monaco-custom-toggle:hover{background:none}.monaco-custom-toggle.monaco-checkbox{height:18px;width:18px;border:1px solid transparent;border-radius:3px;margin-right:9px;margin-left:0;padding:0;opacity:1;background-size:16px!important}.monaco-custom-toggle.monaco-checkbox:not(.checked):before{visibility:hidden}.monaco-toolbar{height:100%}.monaco-toolbar .toolbar-toggle-more{display:inline-block;padding:0}.monaco-tl-row{display:flex;height:100%;align-items:center;position:relative}.monaco-tl-row.disabled{cursor:default}.monaco-tl-indent{height:100%;position:absolute;top:0;left:16px;pointer-events:none}.hide-arrows .monaco-tl-indent{left:12px}.monaco-tl-indent>.indent-guide{display:inline-block;box-sizing:border-box;height:100%;border-left:1px solid transparent}.monaco-workbench:not(.reduce-motion) .monaco-tl-indent>.indent-guide{transition:border-color .1s linear}.monaco-tl-contents,.monaco-tl-twistie{height:100%}.monaco-tl-twistie{font-size:10px;text-align:right;padding-right:6px;flex-shrink:0;width:16px;display:flex!important;align-items:center;justify-content:center;transform:translateX(3px)}.monaco-tl-contents{flex:1;overflow:hidden}.monaco-tl-twistie:before{border-radius:20px}.monaco-tl-twistie.collapsed:before{transform:rotate(-90deg)}.monaco-tl-twistie.codicon-tree-item-loading:before{animation:codicon-spin 1.25s steps(30) infinite}.monaco-tree-type-filter{position:absolute;top:0;display:flex;padding:3px;max-width:200px;z-index:100;margin:0 6px;border:1px solid var(--vscode-widget-border);border-bottom-left-radius:4px;border-bottom-right-radius:4px}.monaco-workbench:not(.reduce-motion) .monaco-tree-type-filter{transition:top .3s}.monaco-tree-type-filter.disabled{top:-40px!important}.monaco-tree-type-filter-grab{display:flex!important;align-items:center;justify-content:center;cursor:grab;margin-right:2px}.monaco-tree-type-filter-grab.grabbing{cursor:grabbing}.monaco-tree-type-filter-input{flex:1}.monaco-tree-type-filter-input .monaco-inputbox{height:23px}.monaco-tree-type-filter-input .monaco-inputbox>.ibwrapper>.input,.monaco-tree-type-filter-input .monaco-inputbox>.ibwrapper>.mirror{padding:2px 4px}.monaco-tree-type-filter-input .monaco-findInput>.controls{top:2px}.monaco-tree-type-filter-actionbar{margin-left:4px}.monaco-tree-type-filter-actionbar .monaco-action-bar .action-label{padding:2px}.monaco-editor .inputarea{min-width:0;min-height:0;margin:0;padding:0;position:absolute;outline:none!important;resize:none;border:none;overflow:hidden;color:transparent;background-color:transparent;z-index:-10}.monaco-editor .inputarea.ime-input{z-index:10;caret-color:var(--vscode-editorCursor-foreground);color:var(--vscode-editor-foreground)}.monaco-editor .blockDecorations-container{position:absolute;top:0;pointer-events:none}.monaco-editor .blockDecorations-block{position:absolute;box-sizing:border-box}.monaco-editor .margin-view-overlays .current-line,.monaco-editor .view-overlays .current-line{display:block;position:absolute;left:0;top:0;box-sizing:border-box}.monaco-editor .margin-view-overlays .current-line.current-line-margin.current-line-margin-both{border-right:0}.monaco-editor .lines-content .cdr{position:absolute}.monaco-editor .glyph-margin{position:absolute;top:0}.monaco-editor .margin-view-overlays .cgmr{position:absolute;display:flex;align-items:center;justify-content:center}.monaco-editor .lines-content .core-guide{position:absolute;box-sizing:border-box}.monaco-editor .lines-content .core-guide-indent{box-shadow:1px 0 0 0 var(--vscode-editorIndentGuide-background) inset}.monaco-editor .lines-content .core-guide-indent-active{box-shadow:1px 0 0 0 var(--vscode-editorIndentGuide-activeBackground,--vscode-editorIndentGuide-background) inset}.monaco-editor .margin-view-overlays .line-numbers{font-variant-numeric:tabular-nums;position:absolute;text-align:right;display:inline-block;vertical-align:middle;box-sizing:border-box;cursor:default;height:100%}.monaco-editor .relative-current-line-number{text-align:left;display:inline-block;width:100%}.monaco-editor .margin-view-overlays .line-numbers.lh-odd{margin-top:1px}.monaco-editor .line-numbers{color:var(--vscode-editorLineNumber-foreground)}.monaco-editor .line-numbers.active-line-number{color:var(--vscode-editorLineNumber-activeForeground)}.mtkcontrol{color:#fff!important;background:#960000!important}.mtkoverflow{background-color:var(--vscode-button-background,--vscode-editor-background);color:var(--vscode-button-foreground,--vscode-editor-foreground);border:1px solid var(--vscode-contrastBorder);border-radius:2px;padding:4px;cursor:pointer}.mtkoverflow:hover{background-color:var(--vscode-button-hoverBackground)}.monaco-editor.no-user-select .lines-content,.monaco-editor.no-user-select .view-line,.monaco-editor.no-user-select .view-lines{user-select:none;-webkit-user-select:none}.monaco-editor.mac .lines-content:hover,.monaco-editor.mac .view-line:hover,.monaco-editor.mac .view-lines:hover{user-select:text;-webkit-user-select:text;-ms-user-select:text}.monaco-editor.enable-user-select{user-select:initial;-webkit-user-select:initial}.monaco-editor .view-lines{white-space:nowrap}.monaco-editor .view-line{position:absolute;width:100%}.monaco-editor .mtkw,.monaco-editor .mtkz{color:var(--vscode-editorWhitespace-foreground)!important}.monaco-editor .mtkz{display:inline-block}.monaco-editor .lines-decorations{position:absolute;top:0;background:#fff}.monaco-editor .margin-view-overlays .cldr{position:absolute;height:100%}.monaco-editor .margin{background-color:var(--vscode-editorGutter-background)}.monaco-editor .margin-view-overlays .cmdr{position:absolute;left:0;width:100%;height:100%}.monaco-editor .minimap.slider-mouseover .minimap-slider{opacity:0;transition:opacity .1s linear}.monaco-editor .minimap.slider-mouseover .minimap-slider.active,.monaco-editor .minimap.slider-mouseover:hover .minimap-slider{opacity:1}.monaco-editor .minimap-slider .minimap-slider-horizontal{background:var(--vscode-minimapSlider-background)}.monaco-editor .minimap-slider:hover .minimap-slider-horizontal{background:var(--vscode-minimapSlider-hoverBackground)}.monaco-editor .minimap-slider.active .minimap-slider-horizontal{background:var(--vscode-minimapSlider-activeBackground)}.monaco-editor .minimap-shadow-visible{box-shadow:var(--vscode-scrollbar-shadow) -6px 0 6px -6px inset}.monaco-editor .minimap-shadow-hidden{position:absolute;width:0}.monaco-editor .minimap-shadow-visible{position:absolute;left:-6px;width:6px}.monaco-editor.no-minimap-shadow .minimap-shadow-visible{position:absolute;left:-1px;width:1px}.minimap.autohide{opacity:0;transition:opacity .5s}.minimap.autohide:hover{opacity:1}.monaco-editor .overlayWidgets{position:absolute;top:0;left:0}.monaco-editor .view-ruler{position:absolute;top:0;box-shadow:1px 0 0 0 var(--vscode-editorRuler-foreground) inset}.monaco-editor .scroll-decoration{position:absolute;top:0;left:0;height:6px;box-shadow:var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset}.monaco-editor .lines-content .cslr{position:absolute}.monaco-editor .focused .selected-text{background-color:var(--vscode-editor-selectionBackground)}.monaco-editor .selected-text{background-color:var(--vscode-editor-inactiveSelectionBackground)}.monaco-editor .top-left-radius{border-top-left-radius:3px}.monaco-editor .bottom-left-radius{border-bottom-left-radius:3px}.monaco-editor .top-right-radius{border-top-right-radius:3px}.monaco-editor .bottom-right-radius{border-bottom-right-radius:3px}.monaco-editor.hc-black .top-left-radius{border-top-left-radius:0}.monaco-editor.hc-black .bottom-left-radius{border-bottom-left-radius:0}.monaco-editor.hc-black .top-right-radius{border-top-right-radius:0}.monaco-editor.hc-black .bottom-right-radius{border-bottom-right-radius:0}.monaco-editor.hc-light .top-left-radius{border-top-left-radius:0}.monaco-editor.hc-light .bottom-left-radius{border-bottom-left-radius:0}.monaco-editor.hc-light .top-right-radius{border-top-right-radius:0}.monaco-editor.hc-light .bottom-right-radius{border-bottom-right-radius:0}.monaco-editor .cursors-layer{position:absolute;top:0}.monaco-editor .cursors-layer>.cursor{position:absolute;overflow:hidden;box-sizing:border-box}.monaco-editor .cursors-layer.cursor-smooth-caret-animation>.cursor{transition:all 80ms}.monaco-editor .cursors-layer.cursor-block-outline-style>.cursor{background:transparent!important;border-style:solid;border-width:1px}.monaco-editor .cursors-layer.cursor-underline-style>.cursor{border-bottom-width:2px;border-bottom-style:solid;background:transparent!important}.monaco-editor .cursors-layer.cursor-underline-thin-style>.cursor{border-bottom-width:1px;border-bottom-style:solid;background:transparent!important}@keyframes monaco-cursor-smooth{0%,20%{opacity:1}60%,to{opacity:0}}@keyframes monaco-cursor-phase{0%,20%{opacity:1}90%,to{opacity:0}}@keyframes monaco-cursor-expand{0%,20%{transform:scaleY(1)}80%,to{transform:scaleY(0)}}.cursor-smooth{animation:monaco-cursor-smooth .5s ease-in-out 0s 20 alternate}.cursor-phase{animation:monaco-cursor-phase .5s ease-in-out 0s 20 alternate}.cursor-expand>.cursor{animation:monaco-cursor-expand .5s ease-in-out 0s 20 alternate}.monaco-editor .mwh{position:absolute;color:var(--vscode-editorWhitespace-foreground)!important}.monaco-diff-editor .diffOverview{z-index:9}.monaco-diff-editor .diffOverview .diffViewport{z-index:10}.monaco-diff-editor.vs .diffOverview{background:rgba(0,0,0,.03)}.monaco-diff-editor.vs-dark .diffOverview{background:hsla(0,0%,100%,.01)}.monaco-scrollable-element.modified-in-monaco-diff-editor.vs-dark .scrollbar,.monaco-scrollable-element.modified-in-monaco-diff-editor.vs .scrollbar{background:transparent}.monaco-scrollable-element.modified-in-monaco-diff-editor.hc-black .scrollbar,.monaco-scrollable-element.modified-in-monaco-diff-editor.hc-light .scrollbar{background:none}.monaco-scrollable-element.modified-in-monaco-diff-editor .slider{z-index:10}.modified-in-monaco-diff-editor .slider.active{background:hsla(0,0%,67.1%,.4)}.modified-in-monaco-diff-editor.hc-black .slider.active,.modified-in-monaco-diff-editor.hc-light .slider.active{background:none}.monaco-diff-editor .delete-sign,.monaco-diff-editor .insert-sign,.monaco-editor .delete-sign,.monaco-editor .insert-sign{font-size:11px!important;opacity:.7!important;display:flex!important;align-items:center}.monaco-diff-editor.hc-black .delete-sign,.monaco-diff-editor.hc-black .insert-sign,.monaco-diff-editor.hc-light .delete-sign,.monaco-diff-editor.hc-light .insert-sign,.monaco-editor.hc-black .delete-sign,.monaco-editor.hc-black .insert-sign,.monaco-editor.hc-light .delete-sign,.monaco-editor.hc-light .insert-sign{opacity:1}.monaco-editor .inline-added-margin-view-zone,.monaco-editor .inline-deleted-margin-view-zone{text-align:right}.monaco-editor .arrow-revert-change{z-index:10;position:absolute}.monaco-editor .arrow-revert-change:hover{cursor:pointer}.monaco-editor .view-zones .view-lines .view-line span{display:inline-block}.monaco-editor .margin-view-zones .lightbulb-glyph:hover{cursor:pointer}.monaco-diff-editor .char-insert,.monaco-editor .char-insert{background-color:var(--vscode-diffEditor-insertedTextBackground)}.monaco-diff-editor .line-insert,.monaco-editor .line-insert{background-color:var(--vscode-diffEditor-insertedLineBackground,--vscode-diffEditor-insertedTextBackground)}.monaco-editor .char-insert,.monaco-editor .line-insert{box-sizing:border-box;border:1px solid var(--vscode-diffEditor-insertedTextBorder)}.monaco-editor.hc-black .char-insert,.monaco-editor.hc-black .line-insert,.monaco-editor.hc-light .char-insert,.monaco-editor.hc-light .line-insert{border-style:dashed}.monaco-editor .char-delete,.monaco-editor .line-delete{box-sizing:border-box;border:1px solid var(--vscode-diffEditor-removedTextBorder)}.monaco-editor.hc-black .char-delete,.monaco-editor.hc-black .line-delete,.monaco-editor.hc-light .char-delete,.monaco-editor.hc-light .line-delete{border-style:dashed}.monaco-diff-editor .gutter-insert,.monaco-editor .gutter-insert,.monaco-editor .inline-added-margin-view-zone{background-color:var(--vscode-diffEditorGutter-insertedLineBackground,--vscode-diffEditor-insertedLineBackground,--vscode-diffEditor-insertedTextBackground)}.monaco-diff-editor .char-delete,.monaco-editor .char-delete{background-color:var(--vscode-diffEditor-removedTextBackground)}.monaco-diff-editor .line-delete,.monaco-editor .line-delete{background-color:var(--vscode-diffEditor-removedLineBackground,--vscode-diffEditor-removedTextBackground)}.monaco-diff-editor .gutter-delete,.monaco-editor .gutter-delete,.monaco-editor .inline-deleted-margin-view-zone{background-color:var(--vscode-diffEditorGutter-removedLineBackground,--vscode-diffEditor-removedLineBackground,--vscode-diffEditor-removedTextBackground)}.monaco-diff-editor.side-by-side .editor.modified{box-shadow:-6px 0 5px -5px var(--vscode-scrollbar-shadow);border-left:1px solid var(--vscode-diffEditor-border)}.monaco-diff-editor .diffViewport{background:var(--vscode-scrollbarSlider-background)}.monaco-diff-editor .diffViewport:hover{background:var(--vscode-scrollbarSlider-hoverBackground)}.monaco-diff-editor .diffViewport:active{background:var(--vscode-scrollbarSlider-activeBackground)}.monaco-diff-editor .diff-review-line-number{text-align:right;display:inline-block;color:var(--vscode-editorLineNumber-foreground)}.monaco-diff-editor .diff-review{position:absolute;user-select:none;-webkit-user-select:none}.monaco-diff-editor .diff-review-summary{padding-left:10px}.monaco-diff-editor .diff-review-shadow{position:absolute;box-shadow:var(--vscode-scrollbar-shadow) 0 -6px 6px -6px inset}.monaco-diff-editor .diff-review-row{white-space:pre}.monaco-diff-editor .diff-review-table{display:table;min-width:100%}.monaco-diff-editor .diff-review-row{display:table-row;width:100%}.monaco-diff-editor .diff-review-spacer{display:inline-block;width:10px;vertical-align:middle}.monaco-diff-editor .diff-review-spacer>.codicon{font-size:9px!important}.monaco-diff-editor .diff-review-actions{display:inline-block;position:absolute;right:10px;top:2px}.monaco-diff-editor .diff-review-actions .action-label{width:16px;height:16px;margin:2px 0}::-ms-clear{display:none}.monaco-editor .editor-widget input{color:inherit}.monaco-editor{position:relative;overflow:visible;-webkit-text-size-adjust:100%;color:var(--vscode-editor-foreground)}.monaco-editor,.monaco-editor-background{background-color:var(--vscode-editor-background)}.monaco-editor .rangeHighlight{background-color:var(--vscode-editor-rangeHighlightBackground);box-sizing:border-box;border:1px solid var(--vscode-editor-rangeHighlightBorder)}.monaco-editor.hc-black .rangeHighlight,.monaco-editor.hc-light .rangeHighlight{border-style:dotted}.monaco-editor .symbolHighlight{background-color:var(--vscode-editor-symbolHighlightBackground);box-sizing:border-box;border:1px solid var(--vscode-editor-symbolHighlightBorder)}.monaco-editor.hc-black .symbolHighlight,.monaco-editor.hc-light .symbolHighlight{border-style:dotted}.monaco-editor .overflow-guard{position:relative;overflow:hidden}.monaco-editor .view-overlays{position:absolute;top:0}.monaco-editor .squiggly-error{border-bottom:4px double var(--vscode-editorError-border)}.monaco-editor .squiggly-error:before{display:block;content:"";width:100%;height:100%;background:var(--vscode-editorError-background)}.monaco-editor .squiggly-warning{border-bottom:4px double var(--vscode-editorWarning-border)}.monaco-editor .squiggly-warning:before{display:block;content:"";width:100%;height:100%;background:var(--vscode-editorWarning-background)}.monaco-editor .squiggly-info{border-bottom:4px double var(--vscode-editorInfo-border)}.monaco-editor .squiggly-info:before{display:block;content:"";width:100%;height:100%;background:var(--vscode-editorInfo-background)}.monaco-editor .squiggly-hint{border-bottom:2px dotted var(--vscode-editorHint-border)}.monaco-editor.showUnused .squiggly-unnecessary{border-bottom:2px dashed var(--vscode-editorUnnecessaryCode-border)}.monaco-editor.showDeprecated .squiggly-inline-deprecated{text-decoration:line-through;text-decoration-color:var(--vscode-editor-foreground,inherit)}.monaco-editor .selection-anchor{background-color:#007acc;width:2px!important}.monaco-editor .bracket-match{box-sizing:border-box;background-color:var(--vscode-editorBracketMatch-background);border:1px solid var(--vscode-editorBracketMatch-border)}.monaco-editor .lightBulbWidget{display:flex;align-items:center;justify-content:center}.monaco-editor .lightBulbWidget:hover{cursor:pointer}.monaco-editor .lightBulbWidget.codicon-light-bulb{color:var(--vscode-editorLightBulb-foreground)}.monaco-editor .lightBulbWidget.codicon-lightbulb-autofix{color:var(--vscode-editorLightBulbAutoFix-foreground,var(--vscode-editorLightBulb-foreground))}.monaco-editor .lightBulbWidget:before{position:relative;z-index:2}.monaco-editor .lightBulbWidget:after{position:absolute;top:0;left:0;content:"";display:block;width:100%;height:100%;opacity:.3;background-color:var(--vscode-editor-background);z-index:1}.monaco-editor .codelens-decoration{overflow:hidden;display:inline-block;text-overflow:ellipsis;white-space:nowrap;color:var(--vscode-editorCodeLens-foreground);line-height:var(--vscode-editorCodeLens-lineHeight);font-size:var(--vscode-editorCodeLens-fontSize);padding-right:calc(var(--vscode-editorCodeLens-fontSize)*0.5);font-feature-settings:var(--vscode-editorCodeLens-fontFeatureSettings);font-family:var(--vscode-editorCodeLens-fontFamily),var(--vscode-editorCodeLens-fontFamilyDefault)}.monaco-editor .codelens-decoration>a,.monaco-editor .codelens-decoration>span{user-select:none;-webkit-user-select:none;white-space:nowrap;vertical-align:sub}.monaco-editor .codelens-decoration>a{text-decoration:none}.monaco-editor .codelens-decoration>a:hover{cursor:pointer}.monaco-editor .codelens-decoration>a:hover,.monaco-editor .codelens-decoration>a:hover .codicon{color:var(--vscode-editorLink-activeForeground)!important}.monaco-editor .codelens-decoration .codicon{vertical-align:middle;color:currentColor!important;color:var(--vscode-editorCodeLens-foreground);line-height:var(--vscode-editorCodeLens-lineHeight);font-size:var(--vscode-editorCodeLens-fontSize)}.monaco-editor .codelens-decoration>a:hover .codicon:before{cursor:pointer}@keyframes fadein{0%{opacity:0;visibility:visible}to{opacity:1}}.monaco-editor .codelens-decoration.fadein{animation:fadein .1s linear}.colorpicker-widget{height:190px;user-select:none;-webkit-user-select:none}.colorpicker-color-decoration,.hc-light .colorpicker-color-decoration{border:.1em solid #000;box-sizing:border-box;margin:.1em .2em 0;width:.8em;height:.8em;line-height:.8em;display:inline-block;cursor:pointer}.hc-black .colorpicker-color-decoration,.vs-dark .colorpicker-color-decoration{border:.1em solid #eee}.colorpicker-header{display:flex;height:24px;position:relative;background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTZEaa/1AAAAHUlEQVQYV2PYvXu3JAi7uLiAMaYAjAGTQBPYLQkAa/0Zef3qRswAAAAASUVORK5CYII=);background-size:9px 9px;image-rendering:pixelated}.colorpicker-header .picked-color{width:216px;display:flex;align-items:center;justify-content:center;line-height:24px;cursor:pointer;color:#fff;flex:1}.colorpicker-header .picked-color .codicon{color:inherit;font-size:14px;position:absolute;left:8px}.colorpicker-header .picked-color.light{color:#000}.colorpicker-header .original-color{width:74px;z-index:inherit;cursor:pointer}.colorpicker-body{display:flex;padding:8px;position:relative}.colorpicker-body .saturation-wrap{overflow:hidden;height:150px;position:relative;min-width:220px;flex:1}.colorpicker-body .saturation-box{height:150px;position:absolute}.colorpicker-body .saturation-selection{width:9px;height:9px;margin:-5px 0 0 -5px;border:1px solid #fff;border-radius:100%;box-shadow:0 0 2px rgba(0,0,0,.8);position:absolute}.colorpicker-body .strip{width:25px;height:150px}.colorpicker-body .hue-strip{position:relative;margin-left:8px;cursor:grab;background:linear-gradient(180deg,red 0,#ff0 17%,#0f0 33%,#0ff 50%,#00f 67%,#f0f 83%,red)}.colorpicker-body .opacity-strip{position:relative;margin-left:8px;cursor:grab;background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTZEaa/1AAAAHUlEQVQYV2PYvXu3JAi7uLiAMaYAjAGTQBPYLQkAa/0Zef3qRswAAAAASUVORK5CYII=);background-size:9px 9px;image-rendering:pixelated}.colorpicker-body .strip.grabbing{cursor:grabbing}.colorpicker-body .slider{position:absolute;top:0;left:-2px;width:calc(100% + 4px);height:4px;box-sizing:border-box;border:1px solid hsla(0,0%,100%,.71);box-shadow:0 0 1px rgba(0,0,0,.85)}.colorpicker-body .strip .overlay{height:150px;pointer-events:none}.monaco-editor.hc-light .dnd-target,.monaco-editor.vs .dnd-target{border-right:2px dotted #000;color:#fff}.monaco-editor.vs-dark .dnd-target{border-right:2px dotted #aeafad;color:#51504f}.monaco-editor.hc-black .dnd-target{border-right:2px dotted #fff;color:#000}.monaco-editor.hc-black.mac.mouse-default .view-lines,.monaco-editor.hc-light.mac.mouse-default .view-lines,.monaco-editor.mouse-default .view-lines,.monaco-editor.vs-dark.mac.mouse-default .view-lines{cursor:default}.monaco-editor.hc-black.mac.mouse-copy .view-lines,.monaco-editor.hc-light.mac.mouse-copy .view-lines,.monaco-editor.mouse-copy .view-lines,.monaco-editor.vs-dark.mac.mouse-copy .view-lines{cursor:copy}.monaco-editor .findOptionsWidget{background-color:var(--vscode-editorWidget-background);color:var(--vscode-editorWidget-foreground);box-shadow:0 0 8px 2px var(--vscode-widget-shadow);border:2px solid var(--vscode-contrastBorder)}.monaco-editor .find-widget{position:absolute;z-index:35;height:33px;overflow:hidden;line-height:19px;transition:transform .2s linear;padding:0 4px;box-sizing:border-box;transform:translateY(calc(-100% - 10px));border-bottom-left-radius:4px;border-bottom-right-radius:4px}.monaco-workbench.reduce-motion .monaco-editor .find-widget{transition:transform 0ms linear}.monaco-editor .find-widget textarea{margin:0}.monaco-editor .find-widget.hiddenEditor{display:none}.monaco-editor .find-widget.replaceToggled>.replace-part{display:flex}.monaco-editor .find-widget.visible{transform:translateY(0)}.monaco-editor .find-widget .monaco-inputbox.synthetic-focus{outline:1px solid -webkit-focus-ring-color;outline-offset:-1px}.monaco-editor .find-widget .monaco-inputbox .input{background-color:transparent;min-height:0}.monaco-editor .find-widget .monaco-findInput .input{font-size:13px}.monaco-editor .find-widget>.find-part,.monaco-editor .find-widget>.replace-part{margin:3px 0 0 17px;font-size:12px;display:flex}.monaco-editor .find-widget>.find-part .monaco-inputbox,.monaco-editor .find-widget>.replace-part .monaco-inputbox{min-height:25px}.monaco-editor .find-widget>.replace-part .monaco-inputbox>.ibwrapper>.mirror{padding-right:22px}.monaco-editor .find-widget>.find-part .monaco-inputbox>.ibwrapper>.input,.monaco-editor .find-widget>.find-part .monaco-inputbox>.ibwrapper>.mirror,.monaco-editor .find-widget>.replace-part .monaco-inputbox>.ibwrapper>.input,.monaco-editor .find-widget>.replace-part .monaco-inputbox>.ibwrapper>.mirror{padding-top:2px;padding-bottom:2px}.monaco-editor .find-widget>.find-part .find-actions,.monaco-editor .find-widget>.replace-part .replace-actions{height:25px;display:flex;align-items:center}.monaco-editor .find-widget .monaco-findInput{vertical-align:middle;display:flex;flex:1}.monaco-editor .find-widget .monaco-findInput .monaco-scrollable-element{width:100%}.monaco-editor .find-widget .monaco-findInput .monaco-scrollable-element .scrollbar.vertical{opacity:0}.monaco-editor .find-widget .matchesCount{display:flex;flex:initial;margin:0 0 0 3px;padding:2px 0 0 2px;height:25px;vertical-align:middle;box-sizing:border-box;text-align:center;line-height:23px}.monaco-editor .find-widget .button{width:16px;height:16px;padding:3px;border-radius:5px;flex:initial;margin-left:3px;background-position:50%;background-repeat:no-repeat;cursor:pointer;display:flex;align-items:center;justify-content:center}.monaco-editor .find-widget .codicon-find-selection{width:22px;height:22px;padding:3px;border-radius:5px}.monaco-editor .find-widget .button.left{margin-left:0;margin-right:3px}.monaco-editor .find-widget .button.wide{width:auto;padding:1px 6px;top:-1px}.monaco-editor .find-widget .button.toggle{position:absolute;top:0;left:3px;width:18px;height:100%;border-radius:0;box-sizing:border-box}.monaco-editor .find-widget .button.toggle.disabled{display:none}.monaco-editor .find-widget .disabled{color:var(--vscode-disabledForeground);cursor:default}.monaco-editor .find-widget>.replace-part{display:none}.monaco-editor .find-widget>.replace-part>.monaco-findInput{position:relative;display:flex;vertical-align:middle;flex:auto;flex-grow:0;flex-shrink:0}.monaco-editor .find-widget>.replace-part>.monaco-findInput>.controls{position:absolute;top:3px;right:2px}.monaco-editor .find-widget.reduced-find-widget .matchesCount{display:none}.monaco-editor .find-widget.narrow-find-widget{max-width:257px!important}.monaco-editor .find-widget.collapsed-find-widget{max-width:170px!important}.monaco-editor .find-widget.collapsed-find-widget .button.next,.monaco-editor .find-widget.collapsed-find-widget .button.previous,.monaco-editor .find-widget.collapsed-find-widget .button.replace,.monaco-editor .find-widget.collapsed-find-widget .button.replace-all,.monaco-editor .find-widget.collapsed-find-widget>.find-part .monaco-findInput .controls{display:none}.monaco-editor .findMatch{animation-duration:0;animation-name:inherit!important}.monaco-editor .find-widget .monaco-sash{left:0!important}.monaco-editor.hc-black .find-widget .button:before{position:relative;top:1px;left:2px}.monaco-editor .margin-view-overlays .codicon-folding-collapsed,.monaco-editor .margin-view-overlays .codicon-folding-expanded,.monaco-editor .margin-view-overlays .codicon-folding-manual-collapsed,.monaco-editor .margin-view-overlays .codicon-folding-manual-expanded{cursor:pointer;opacity:0;transition:opacity .5s;display:flex;align-items:center;justify-content:center;font-size:140%;margin-left:2px}.monaco-workbench.reduce-motion .monaco-editor .margin-view-overlays .codicon-folding-collapsed,.monaco-workbench.reduce-motion .monaco-editor .margin-view-overlays .codicon-folding-expanded,.monaco-workbench.reduce-motion .monaco-editor .margin-view-overlays .codicon-folding-manual-collapsed,.monaco-workbench.reduce-motion .monaco-editor .margin-view-overlays .codicon-folding-manual-expanded{transition:initial}.monaco-editor .margin-view-overlays .codicon.alwaysShowFoldIcons,.monaco-editor .margin-view-overlays .codicon.codicon-folding-collapsed,.monaco-editor .margin-view-overlays .codicon.codicon-folding-manual-collapsed,.monaco-editor .margin-view-overlays:hover .codicon{opacity:1}.monaco-editor .inline-folded:after{color:grey;margin:.1em .2em 0;content:"\22EF";display:inline;line-height:1em;cursor:pointer}.monaco-editor .folded-background{background-color:var(--vscode-editor-foldBackground)}.monaco-editor .cldr.codicon.codicon-folding-collapsed,.monaco-editor .cldr.codicon.codicon-folding-expanded,.monaco-editor .cldr.codicon.codicon-folding-manual-collapsed,.monaco-editor .cldr.codicon.codicon-folding-manual-expanded{color:var(--vscode-editorGutter-foldingControlForeground)!important}.monaco-editor .peekview-widget .head .peekview-title .severity-icon{display:inline-block;vertical-align:text-top;margin-right:4px}.monaco-editor .marker-widget{text-overflow:ellipsis;white-space:nowrap}.monaco-editor .marker-widget>.stale{opacity:.6;font-style:italic}.monaco-editor .marker-widget .title{display:inline-block;padding-right:5px}.monaco-editor .marker-widget .descriptioncontainer{position:absolute;white-space:pre;user-select:text;-webkit-user-select:text;padding:8px 12px 0 20px}.monaco-editor .marker-widget .descriptioncontainer .message{display:flex;flex-direction:column}.monaco-editor .marker-widget .descriptioncontainer .message .details{padding-left:6px}.monaco-editor .marker-widget .descriptioncontainer .message .source,.monaco-editor .marker-widget .descriptioncontainer .message span.code{opacity:.6}.monaco-editor .marker-widget .descriptioncontainer .message a.code-link{opacity:.6;color:inherit}.monaco-editor .marker-widget .descriptioncontainer .message a.code-link:before{content:"("}.monaco-editor .marker-widget .descriptioncontainer .message a.code-link:after{content:")"}.monaco-editor .marker-widget .descriptioncontainer .message a.code-link>span{text-decoration:underline;border-bottom:1px solid transparent;text-underline-position:under;color:var(--vscode-textLink-foreground);color:var(--vscode-textLink-activeForeground)}.monaco-editor .marker-widget .descriptioncontainer .filename{cursor:pointer}.monaco-editor .goto-definition-link{text-decoration:underline;cursor:pointer;color:var(--vscode-editorLink-activeForeground)!important}.monaco-editor .zone-widget .zone-widget-container.reference-zone-widget{border-top-width:1px;border-bottom-width:1px}.monaco-editor .reference-zone-widget .inline{display:inline-block;vertical-align:top}.monaco-editor .reference-zone-widget .messages{height:100%;width:100%;text-align:center;padding:3em 0}.monaco-editor .reference-zone-widget .ref-tree{line-height:23px;background-color:var(--vscode-peekViewResult-background);color:var(--vscode-peekViewResult-lineForeground)}.monaco-editor .reference-zone-widget .ref-tree .reference{text-overflow:ellipsis;overflow:hidden}.monaco-editor .reference-zone-widget .ref-tree .reference-file{display:inline-flex;width:100%;height:100%;color:var(--vscode-peekViewResult-fileForeground)}.monaco-editor .reference-zone-widget .ref-tree .monaco-list:focus .selected .reference-file{color:inherit!important}.monaco-editor .reference-zone-widget .ref-tree .monaco-list:focus .monaco-list-rows>.monaco-list-row.selected:not(.highlighted){background-color:var(--vscode-peekViewResult-selectionBackground);color:var(--vscode-peekViewResult-selectionForeground)!important}.monaco-editor .reference-zone-widget .ref-tree .reference-file .count{margin-right:12px;margin-left:auto}.monaco-editor .reference-zone-widget .ref-tree .referenceMatch .highlight{background-color:var(--vscode-peekViewResult-matchHighlightBackground)}.monaco-editor .reference-zone-widget .preview .reference-decoration{background-color:var(--vscode-peekViewEditor-matchHighlightBackground);border:2px solid var(--vscode-peekViewEditor-matchHighlightBorder);box-sizing:border-box}.monaco-editor .reference-zone-widget .preview .monaco-editor .inputarea.ime-input,.monaco-editor .reference-zone-widget .preview .monaco-editor .monaco-editor-background{background-color:var(--vscode-peekViewEditor-background)}.monaco-editor .reference-zone-widget .preview .monaco-editor .margin{background-color:var(--vscode-peekViewEditorGutter-background)}.monaco-editor.hc-black .reference-zone-widget .ref-tree .reference-file,.monaco-editor.hc-light .reference-zone-widget .ref-tree .reference-file{font-weight:700}.monaco-editor.hc-black .reference-zone-widget .ref-tree .referenceMatch .highlight,.monaco-editor.hc-light .reference-zone-widget .ref-tree .referenceMatch .highlight{border:1px dotted var(--vscode-contrastActiveBorder,transparent);box-sizing:border-box}.monaco-editor .hoverHighlight{background-color:var(--vscode-editor-hoverHighlightBackground)}.monaco-editor .monaco-hover{color:var(--vscode-editorHoverWidget-foreground);background-color:var(--vscode-editorHoverWidget-background);border:1px solid var(--vscode-editorHoverWidget-border)}.monaco-editor .monaco-hover a{color:var(--vscode-textLink-foreground)}.monaco-editor .monaco-hover a:hover{color:var(--vscode-textLink-activeForeground)}.monaco-editor .monaco-hover .hover-row .actions{background-color:var(--vscode-editorHoverWidget-statusBarBackground)}.monaco-editor .monaco-hover code{background-color:var(--vscode-textCodeBlock-background)}.monaco-editor.vs .valueSetReplacement{outline:solid 2px var(--vscode-editorBracketMatch-border)}.monaco-editor .suggest-preview-additional-widget{white-space:nowrap}.monaco-editor .suggest-preview-additional-widget .content-spacer{color:transparent;white-space:pre}.monaco-editor .suggest-preview-additional-widget .button{display:inline-block;cursor:pointer;text-decoration:underline;text-underline-position:under}.monaco-editor .ghost-text-hidden{opacity:0;font-size:0}.monaco-editor .ghost-text-decoration,.monaco-editor .suggest-preview-text{font-style:italic}.monaco-editor .inline-completion-text-to-replace{text-decoration:underline;text-underline-position:under}.monaco-editor .ghost-text-decoration,.monaco-editor .ghost-text-decoration-preview,.monaco-editor .suggest-preview-text .ghost-text{color:var(--vscode-editorGhostText-foreground)!important;background-color:var(--vscode-editorGhostText-background);border:1px solid var(--vscode-editorGhostText-border)}.monaco-editor .inlineSuggestionsHints.withBorder{z-index:39;color:var(--vscode-editorHoverWidget-foreground);background-color:var(--vscode-editorHoverWidget-background);border:1px solid var(--vscode-editorHoverWidget-border)}.monaco-editor .inlineSuggestionsHints a,.monaco-editor .inlineSuggestionsHints a:hover{color:var(--vscode-foreground)}.monaco-editor .inlineSuggestionsHints .keybinding{display:flex;margin-left:4px;opacity:.6}.monaco-editor .inlineSuggestionsHints .keybinding .monaco-keybinding-key{font-size:8px;padding:2px 3px}.monaco-editor .inlineSuggestionsHints .custom-actions .action-item:nth-child(2) a{display:flex;min-width:19px;justify-content:center}.monaco-editor .inlineSuggestionStatusBarItemLabel{margin-right:2px}.monaco-editor .linked-editing-decoration{background-color:var(--vscode-editor-linkedEditingBackground);border-left-color:var(--vscode-editor-linkedEditingBackground)}.monaco-editor .detected-link,.monaco-editor .detected-link-active{text-decoration:underline;text-underline-position:under}.monaco-editor .detected-link-active{cursor:pointer;color:var(--vscode-editorLink-activeForeground)!important}.monaco-editor .rendered-markdown kbd{background-color:var(--vscode-keybindingLabel-background);color:var(--vscode-keybindingLabel-foreground);border-radius:3px;border:1px solid var(--vscode-keybindingLabel-border);border-bottom-color:var(--vscode-keybindingLabel-bottomBorder);box-shadow:inset 0 -1px 0 var(--vscode-widget-shadow);vertical-align:middle;padding:1px 3px}.monaco-editor .monaco-editor-overlaymessage{padding-bottom:8px;z-index:10000}.monaco-editor .monaco-editor-overlaymessage.below{padding-bottom:0;padding-top:8px;z-index:10000}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.monaco-editor .monaco-editor-overlaymessage.fadeIn{animation:fadeIn .15s ease-out}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}.monaco-editor .monaco-editor-overlaymessage.fadeOut{animation:fadeOut .1s ease-out}.monaco-editor .monaco-editor-overlaymessage .message{padding:1px 4px;color:var(--vscode-inputValidation-infoForeground);background-color:var(--vscode-inputValidation-infoBackground);border:1px solid var(--vscode-inputValidation-infoBorder)}.monaco-editor.hc-black .monaco-editor-overlaymessage .message,.monaco-editor.hc-light .monaco-editor-overlaymessage .message{border-width:2px}.monaco-editor .monaco-editor-overlaymessage .anchor{width:0!important;height:0!important;z-index:1000;border:8px solid transparent;position:absolute}.monaco-editor .monaco-editor-overlaymessage .anchor.top{border-bottom-color:var(--vscode-inputValidation-infoBorder)}.monaco-editor .monaco-editor-overlaymessage .anchor.below{border-top-color:var(--vscode-inputValidation-infoBorder)}.monaco-editor .monaco-editor-overlaymessage.below .anchor.below,.monaco-editor .monaco-editor-overlaymessage:not(.below) .anchor.top{display:none}.monaco-editor .monaco-editor-overlaymessage.below .anchor.top{display:inherit;top:-8px}.monaco-editor .parameter-hints-widget{z-index:39;display:flex;flex-direction:column;line-height:1.5em;cursor:default;color:var(--vscode-editorHoverWidget-foreground);background-color:var(--vscode-editorHoverWidget-background);border:1px solid var(--vscode-editorHoverWidget-border)}.hc-black .monaco-editor .parameter-hints-widget,.hc-light .monaco-editor .parameter-hints-widget{border-width:2px}.monaco-editor .parameter-hints-widget>.phwrapper{max-width:440px;display:flex;flex-direction:row}.monaco-editor .parameter-hints-widget.multiple{min-height:3.3em;padding:0}.monaco-editor .parameter-hints-widget.multiple .body:before{content:"";display:block;height:100%;position:absolute;opacity:.5;border-left:1px solid var(--vscode-editorHoverWidget-border)}.monaco-editor .parameter-hints-widget p,.monaco-editor .parameter-hints-widget ul{margin:8px 0}.monaco-editor .parameter-hints-widget .body,.monaco-editor .parameter-hints-widget .monaco-scrollable-element{display:flex;flex:1;flex-direction:column;min-height:100%}.monaco-editor .parameter-hints-widget .signature{padding:4px 5px;position:relative}.monaco-editor .parameter-hints-widget .signature.has-docs:after{content:"";display:block;position:absolute;left:0;width:100%;padding-top:4px;opacity:.5;border-bottom:1px solid var(--vscode-editorHoverWidget-border)}.monaco-editor .parameter-hints-widget .docs{padding:0 10px 0 5px;white-space:pre-wrap}.monaco-editor .parameter-hints-widget .docs.empty{display:none}.monaco-editor .parameter-hints-widget .docs a{color:var(--vscode-textLink-foreground)}.monaco-editor .parameter-hints-widget .docs a:hover{color:var(--vscode-textLink-activeForeground);cursor:pointer}.monaco-editor .parameter-hints-widget .docs .markdown-docs{white-space:normal}.monaco-editor .parameter-hints-widget .docs code{font-family:var(--monaco-monospace-font);border-radius:3px;padding:0 .4em;background-color:var(--vscode-textCodeBlock-background)}.monaco-editor .parameter-hints-widget .docs .code,.monaco-editor .parameter-hints-widget .docs .monaco-tokenized-source{white-space:pre-wrap}.monaco-editor .parameter-hints-widget .controls{display:none;flex-direction:column;align-items:center;min-width:22px;justify-content:flex-end}.monaco-editor .parameter-hints-widget.multiple .controls{display:flex;padding:0 2px}.monaco-editor .parameter-hints-widget.multiple .button{width:16px;height:16px;background-repeat:no-repeat;cursor:pointer}.monaco-editor .parameter-hints-widget .button.previous{bottom:24px}.monaco-editor .parameter-hints-widget .overloads{text-align:center;height:12px;line-height:12px;font-family:var(--monaco-monospace-font)}.monaco-editor .parameter-hints-widget .signature .parameter.active{color:var(--vscode-editorHoverWidget-highlightForeground);font-weight:700}.monaco-editor .parameter-hints-widget .documentation-parameter>.parameter{font-weight:700;margin-right:.5em}.monaco-editor .peekview-widget .head{box-sizing:border-box;display:flex;justify-content:space-between;flex-wrap:nowrap}.monaco-editor .peekview-widget .head .peekview-title{display:flex;align-items:baseline;font-size:13px;margin-left:20px;min-width:0;text-overflow:ellipsis;overflow:hidden}.monaco-editor .peekview-widget .head .peekview-title.clickable{cursor:pointer}.monaco-editor .peekview-widget .head .peekview-title .dirname:not(:empty){font-size:.9em;margin-left:.5em}.monaco-editor .peekview-widget .head .peekview-title .dirname,.monaco-editor .peekview-widget .head .peekview-title .filename,.monaco-editor .peekview-widget .head .peekview-title .meta{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.monaco-editor .peekview-widget .head .peekview-title .meta:not(:empty):before{content:"-";padding:0 .3em}.monaco-editor .peekview-widget .head .peekview-actions{flex:1;text-align:right;padding-right:2px}.monaco-editor .peekview-widget .head .peekview-actions>.monaco-action-bar{display:inline-block}.monaco-editor .peekview-widget .head .peekview-actions>.monaco-action-bar,.monaco-editor .peekview-widget .head .peekview-actions>.monaco-action-bar>.actions-container{height:100%}.monaco-editor .peekview-widget>.body{border-top:1px solid;position:relative}.monaco-editor .peekview-widget .head .peekview-title .codicon{margin-right:4px;align-self:center}.monaco-editor .peekview-widget .monaco-list .monaco-list-row.focused .codicon{color:inherit!important}.monaco-editor .rename-box{z-index:100;color:inherit;border-radius:4px}.monaco-editor .rename-box.preview{padding:4px 4px 0}.monaco-editor .rename-box .rename-input{padding:3px;border-radius:2px}.monaco-editor .rename-box .rename-label{display:none;opacity:.8}.monaco-editor .rename-box.preview .rename-label{display:inherit}.monaco-editor .snippet-placeholder{min-width:2px;outline-style:solid;outline-width:1px;background-color:var(--vscode-editor-snippetTabstopHighlightBackground,transparent);outline-color:var(--vscode-editor-snippetTabstopHighlightBorder,transparent)}.monaco-editor .finish-snippet-placeholder{outline-style:solid;outline-width:1px;background-color:var(--vscode-editor-snippetFinalTabstopHighlightBackground,transparent);outline-color:var(--vscode-editor-snippetFinalTabstopHighlightBorder,transparent)}.monaco-editor .sticky-line{color:var(--vscode-editorLineNumber-foreground);overflow:hidden;white-space:nowrap;display:inline-block}.monaco-editor .sticky-line-number{text-align:right;float:left}.monaco-editor .sticky-line-root{background-color:inherit;overflow:hidden;white-space:nowrap;width:100%}.monaco-editor.hc-black .sticky-widget,.monaco-editor.hc-light .sticky-widget{border-bottom:1px solid var(--vscode-contrastBorder)}.monaco-editor .sticky-line-root:hover{background-color:var(--vscode-editorStickyScrollHover-background);cursor:pointer}.monaco-editor .sticky-widget{width:100%;box-shadow:var(--vscode-scrollbar-shadow) 0 3px 2px -2px;z-index:4;background-color:var(--vscode-editorStickyScroll-background)}.monaco-editor .sticky-widget.peek{background-color:var(--vscode-peekViewEditorStickyScroll-background)}.monaco-editor .suggest-widget{width:430px;z-index:40;display:flex;flex-direction:column}.monaco-editor .suggest-widget.message{flex-direction:row;align-items:center}.monaco-editor .suggest-details,.monaco-editor .suggest-widget{flex:0 1 auto;width:100%;border:1px solid var(--vscode-editorSuggestWidget-border);background-color:var(--vscode-editorSuggestWidget-background)}.monaco-editor.hc-black .suggest-details,.monaco-editor.hc-black .suggest-widget,.monaco-editor.hc-light .suggest-details,.monaco-editor.hc-light .suggest-widget{border-width:2px}.monaco-editor .suggest-widget .suggest-status-bar{box-sizing:border-box;display:none;flex-flow:row nowrap;justify-content:space-between;width:100%;font-size:80%;padding:0 4px;border-top:1px solid var(--vscode-editorSuggestWidget-border);overflow:hidden}.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar{display:flex}.monaco-editor .suggest-widget .suggest-status-bar .left{padding-right:8px}.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar .action-label{color:var(--vscode-editorSuggestWidgetStatus-foreground)}.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar .action-item:not(:last-of-type) .action-label{margin-right:0}.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar .action-item:not(:last-of-type) .action-label:after{content:", ";margin-right:.3em}.monaco-editor .suggest-widget.with-status-bar .monaco-list .monaco-list-row.focused.string-label>.contents>.main>.right>.readMore,.monaco-editor .suggest-widget.with-status-bar .monaco-list .monaco-list-row>.contents>.main>.right>.readMore{display:none}.monaco-editor .suggest-widget.with-status-bar:not(.docs-side) .monaco-list .monaco-list-row:hover>.contents>.main>.right.can-expand-details>.details-label{width:100%}.monaco-editor .suggest-widget>.message{padding-left:22px}.monaco-editor .suggest-widget>.tree{height:100%;width:100%}.monaco-editor .suggest-widget .monaco-list{user-select:none;-webkit-user-select:none}.monaco-editor .suggest-widget .monaco-list .monaco-list-row{display:flex;-mox-box-sizing:border-box;box-sizing:border-box;padding-right:10px;background-repeat:no-repeat;background-position:2px 2px;white-space:nowrap;cursor:pointer;touch-action:none}.monaco-editor .suggest-widget .monaco-list .monaco-list-row.focused{color:var(--vscode-editorSuggestWidget-selectedForeground)}.monaco-editor .suggest-widget .monaco-list .monaco-list-row.focused .codicon{color:var(--vscode-editorSuggestWidget-selectedIconForeground)}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents{flex:1;height:100%;overflow:hidden;padding-left:2px}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main{display:flex;overflow:hidden;text-overflow:ellipsis;white-space:pre;justify-content:space-between}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left,.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right{display:flex}.monaco-editor .suggest-widget .monaco-list .monaco-list-row:not(.focused)>.contents>.main .monaco-icon-label{color:var(--vscode-editorSuggestWidget-foreground)}.monaco-editor .suggest-widget:not(.frozen) .monaco-highlighted-label .highlight{font-weight:700}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main .monaco-highlighted-label .highlight{color:var(--vscode-editorSuggestWidget-highlightForeground)}.monaco-editor .suggest-widget .monaco-list .monaco-list-row.focused>.contents>.main .monaco-highlighted-label .highlight{color:var(--vscode-editorSuggestWidget-focusHighlightForeground)}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.codicon-close,.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.readMore:before{color:inherit;opacity:1;font-size:14px;cursor:pointer}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.codicon-close{position:absolute;top:6px;right:2px}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.codicon-close:hover,.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.readMore:hover{opacity:1}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label{opacity:.7}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.signature-label{overflow:hidden;text-overflow:ellipsis;opacity:.6}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.qualifier-label{margin-left:12px;opacity:.4;font-size:85%;line-height:normal;text-overflow:ellipsis;overflow:hidden;align-self:center}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label{font-size:85%;margin-left:1.1em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label>.monaco-tokenized-source{display:inline}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label{display:none}.monaco-editor .suggest-widget.docs-side .monaco-list .monaco-list-row.focused:not(.string-label)>.contents>.main>.right>.details-label,.monaco-editor .suggest-widget .monaco-list .monaco-list-row:not(.string-label)>.contents>.main>.right>.details-label,.monaco-editor .suggest-widget:not(.shows-details) .monaco-list .monaco-list-row.focused>.contents>.main>.right>.details-label{display:inline}.monaco-editor .suggest-widget:not(.docs-side) .monaco-list .monaco-list-row.focused:hover>.contents>.main>.right.can-expand-details>.details-label{width:calc(100% - 26px)}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left{flex-shrink:1;flex-grow:1;overflow:hidden}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.monaco-icon-label{flex-shrink:0}.monaco-editor .suggest-widget .monaco-list .monaco-list-row:not(.string-label)>.contents>.main>.left>.monaco-icon-label{max-width:100%}.monaco-editor .suggest-widget .monaco-list .monaco-list-row.string-label>.contents>.main>.left>.monaco-icon-label{flex-shrink:1}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right{overflow:hidden;flex-shrink:4;max-width:70%}.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.readMore{display:inline-block;position:absolute;right:10px;width:18px;height:18px;visibility:hidden}.monaco-editor .suggest-widget.docs-side .monaco-list .monaco-list-row>.contents>.main>.right>.readMore{display:none!important}.monaco-editor .suggest-widget .monaco-list .monaco-list-row.string-label>.contents>.main>.right>.readMore{display:none}.monaco-editor .suggest-widget .monaco-list .monaco-list-row.focused.string-label>.contents>.main>.right>.readMore{display:inline-block}.monaco-editor .suggest-widget .monaco-list .monaco-list-row.focused:hover>.contents>.main>.right>.readMore{visibility:visible}.monaco-editor .suggest-widget .monaco-list .monaco-list-row .monaco-icon-label.deprecated{opacity:.66;text-decoration:unset}.monaco-editor .suggest-widget .monaco-list .monaco-list-row .monaco-icon-label.deprecated>.monaco-icon-label-container>.monaco-icon-name-container{text-decoration:line-through}.monaco-editor .suggest-widget .monaco-list .monaco-list-row .monaco-icon-label:before{height:100%}.monaco-editor .suggest-widget .monaco-list .monaco-list-row .icon{display:block;height:16px;width:16px;margin-left:2px;background-repeat:no-repeat;background-size:80%;background-position:50%}.monaco-editor .suggest-widget .monaco-list .monaco-list-row .icon.hide{display:none}.monaco-editor .suggest-widget .monaco-list .monaco-list-row .suggest-icon{display:flex;align-items:center;margin-right:4px}.monaco-editor .suggest-widget.no-icons .monaco-list .monaco-list-row .icon,.monaco-editor .suggest-widget.no-icons .monaco-list .monaco-list-row .suggest-icon:before{display:none}.monaco-editor .suggest-widget .monaco-list .monaco-list-row .icon.customcolor .colorspan{margin:0 0 0 .3em;border:.1em solid #000;width:.7em;height:.7em;display:inline-block}.monaco-editor .suggest-details-container{z-index:41}.monaco-editor .suggest-details{display:flex;flex-direction:column;cursor:default;color:var(--vscode-editorSuggestWidget-foreground)}.monaco-editor .suggest-details.focused{border-color:var(--vscode-focusBorder)}.monaco-editor .suggest-details a{color:var(--vscode-textLink-foreground)}.monaco-editor .suggest-details a:hover{color:var(--vscode-textLink-activeForeground)}.monaco-editor .suggest-details code{background-color:var(--vscode-textCodeBlock-background)}.monaco-editor .suggest-details.no-docs{display:none}.monaco-editor .suggest-details>.monaco-scrollable-element{flex:1}.monaco-editor .suggest-details>.monaco-scrollable-element>.body{box-sizing:border-box;height:100%;width:100%}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.type{flex:2;overflow:hidden;text-overflow:ellipsis;opacity:.7;white-space:pre;margin:0 24px 0 0;padding:4px 0 12px 5px}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.type.auto-wrap{white-space:normal;word-break:break-all}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs{margin:0;padding:4px 5px;white-space:pre-wrap}.monaco-editor .suggest-details.no-type>.monaco-scrollable-element>.body>.docs{margin-right:24px;overflow:hidden}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs{padding:0;white-space:normal;min-height:calc(1rem + 8px)}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>div,.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>span:not(:empty){padding:4px 5px}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>div>p:first-child{margin-top:0}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>div>p:last-child{margin-bottom:0}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs .monaco-tokenized-source{white-space:pre}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs .code{white-space:pre-wrap;word-wrap:break-word}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs .codicon{vertical-align:sub}.monaco-editor .suggest-details>.monaco-scrollable-element>.body>p:empty{display:none}.monaco-editor .suggest-details code{border-radius:3px;padding:0 .4em}.monaco-editor .suggest-details ol,.monaco-editor .suggest-details ul{padding-left:20px}.monaco-editor .suggest-details p code{font-family:var(--monaco-monospace-font)}.monaco-editor .codicon.codicon-symbol-array,.monaco-workbench .codicon.codicon-symbol-array{color:var(--vscode-symbolIcon-arrayForeground)}.monaco-editor .codicon.codicon-symbol-boolean,.monaco-workbench .codicon.codicon-symbol-boolean{color:var(--vscode-symbolIcon-booleanForeground)}.monaco-editor .codicon.codicon-symbol-class,.monaco-workbench .codicon.codicon-symbol-class{color:var(--vscode-symbolIcon-classForeground)}.monaco-editor .codicon.codicon-symbol-method,.monaco-workbench .codicon.codicon-symbol-method{color:var(--vscode-symbolIcon-methodForeground)}.monaco-editor .codicon.codicon-symbol-color,.monaco-workbench .codicon.codicon-symbol-color{color:var(--vscode-symbolIcon-colorForeground)}.monaco-editor .codicon.codicon-symbol-constant,.monaco-workbench .codicon.codicon-symbol-constant{color:var(--vscode-symbolIcon-constantForeground)}.monaco-editor .codicon.codicon-symbol-constructor,.monaco-workbench .codicon.codicon-symbol-constructor{color:var(--vscode-symbolIcon-constructorForeground)}.monaco-editor .codicon.codicon-symbol-enum,.monaco-editor .codicon.codicon-symbol-value,.monaco-workbench .codicon.codicon-symbol-enum,.monaco-workbench .codicon.codicon-symbol-value{color:var(--vscode-symbolIcon-enumeratorForeground)}.monaco-editor .codicon.codicon-symbol-enum-member,.monaco-workbench .codicon.codicon-symbol-enum-member{color:var(--vscode-symbolIcon-enumeratorMemberForeground)}.monaco-editor .codicon.codicon-symbol-event,.monaco-workbench .codicon.codicon-symbol-event{color:var(--vscode-symbolIcon-eventForeground)}.monaco-editor .codicon.codicon-symbol-field,.monaco-workbench .codicon.codicon-symbol-field{color:var(--vscode-symbolIcon-fieldForeground)}.monaco-editor .codicon.codicon-symbol-file,.monaco-workbench .codicon.codicon-symbol-file{color:var(--vscode-symbolIcon-fileForeground)}.monaco-editor .codicon.codicon-symbol-folder,.monaco-workbench .codicon.codicon-symbol-folder{color:var(--vscode-symbolIcon-folderForeground)}.monaco-editor .codicon.codicon-symbol-function,.monaco-workbench .codicon.codicon-symbol-function{color:var(--vscode-symbolIcon-functionForeground)}.monaco-editor .codicon.codicon-symbol-interface,.monaco-workbench .codicon.codicon-symbol-interface{color:var(--vscode-symbolIcon-interfaceForeground)}.monaco-editor .codicon.codicon-symbol-key,.monaco-workbench .codicon.codicon-symbol-key{color:var(--vscode-symbolIcon-keyForeground)}.monaco-editor .codicon.codicon-symbol-keyword,.monaco-workbench .codicon.codicon-symbol-keyword{color:var(--vscode-symbolIcon-keywordForeground)}.monaco-editor .codicon.codicon-symbol-module,.monaco-workbench .codicon.codicon-symbol-module{color:var(--vscode-symbolIcon-moduleForeground)}.monaco-editor .codicon.codicon-symbol-namespace,.monaco-workbench .codicon.codicon-symbol-namespace{color:var(--vscode-symbolIcon-namespaceForeground)}.monaco-editor .codicon.codicon-symbol-null,.monaco-workbench .codicon.codicon-symbol-null{color:var(--vscode-symbolIcon-nullForeground)}.monaco-editor .codicon.codicon-symbol-number,.monaco-workbench .codicon.codicon-symbol-number{color:var(--vscode-symbolIcon-numberForeground)}.monaco-editor .codicon.codicon-symbol-object,.monaco-workbench .codicon.codicon-symbol-object{color:var(--vscode-symbolIcon-objectForeground)}.monaco-editor .codicon.codicon-symbol-operator,.monaco-workbench .codicon.codicon-symbol-operator{color:var(--vscode-symbolIcon-operatorForeground)}.monaco-editor .codicon.codicon-symbol-package,.monaco-workbench .codicon.codicon-symbol-package{color:var(--vscode-symbolIcon-packageForeground)}.monaco-editor .codicon.codicon-symbol-property,.monaco-workbench .codicon.codicon-symbol-property{color:var(--vscode-symbolIcon-propertyForeground)}.monaco-editor .codicon.codicon-symbol-reference,.monaco-workbench .codicon.codicon-symbol-reference{color:var(--vscode-symbolIcon-referenceForeground)}.monaco-editor .codicon.codicon-symbol-snippet,.monaco-workbench .codicon.codicon-symbol-snippet{color:var(--vscode-symbolIcon-snippetForeground)}.monaco-editor .codicon.codicon-symbol-string,.monaco-workbench .codicon.codicon-symbol-string{color:var(--vscode-symbolIcon-stringForeground)}.monaco-editor .codicon.codicon-symbol-struct,.monaco-workbench .codicon.codicon-symbol-struct{color:var(--vscode-symbolIcon-structForeground)}.monaco-editor .codicon.codicon-symbol-text,.monaco-workbench .codicon.codicon-symbol-text{color:var(--vscode-symbolIcon-textForeground)}.monaco-editor .codicon.codicon-symbol-type-parameter,.monaco-workbench .codicon.codicon-symbol-type-parameter{color:var(--vscode-symbolIcon-typeParameterForeground)}.monaco-editor .codicon.codicon-symbol-unit,.monaco-workbench .codicon.codicon-symbol-unit{color:var(--vscode-symbolIcon-unitForeground)}.monaco-editor .codicon.codicon-symbol-variable,.monaco-workbench .codicon.codicon-symbol-variable{color:var(--vscode-symbolIcon-variableForeground)}.editor-banner{box-sizing:border-box;cursor:default;width:100%;font-size:12px;display:flex;overflow:visible;height:26px;background:var(--vscode-banner-background)}.editor-banner .icon-container{display:flex;flex-shrink:0;align-items:center;padding:0 6px 0 10px}.editor-banner .icon-container.custom-icon{background-repeat:no-repeat;background-position:50%;background-size:16px;width:16px;padding:0;margin:0 6px 0 10px}.editor-banner .message-container{display:flex;align-items:center;line-height:26px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.editor-banner .message-container p{margin-block-start:0;margin-block-end:0}.editor-banner .message-actions-container{flex-grow:1;flex-shrink:0;line-height:26px;margin:0 4px}.editor-banner .message-actions-container a.monaco-button{width:inherit;margin:2px 8px;padding:0 12px}.editor-banner .message-actions-container a{padding:3px;margin-left:12px;text-decoration:underline}.editor-banner .action-container{padding:0 10px 0 6px}.editor-banner{background-color:var(--vscode-banner-background)}.editor-banner,.editor-banner .action-container .codicon,.editor-banner .message-actions-container .monaco-link{color:var(--vscode-banner-foreground)}.editor-banner .icon-container .codicon{color:var(--vscode-banner-iconForeground)}.monaco-editor .unicode-highlight{border:1px solid var(--vscode-editorUnicodeHighlight-border);background-color:var(--vscode-editorUnicodeHighlight-background);box-sizing:border-box}.monaco-editor .focused .selectionHighlight{background-color:var(--vscode-editor-selectionHighlightBackground);box-sizing:border-box;border:1px solid var(--vscode-editor-selectionHighlightBorder)}.monaco-editor.hc-black .focused .selectionHighlight,.monaco-editor.hc-light .focused .selectionHighlight{border-style:dotted}.monaco-editor .wordHighlight{background-color:var(--vscode-editor-wordHighlightBackground);box-sizing:border-box;border:1px solid var(--vscode-editor-wordHighlightBorder)}.monaco-editor.hc-black .wordHighlight,.monaco-editor.hc-light .wordHighlight{border-style:dotted}.monaco-editor .wordHighlightStrong{background-color:var(--vscode-editor-wordHighlightStrongBackground);box-sizing:border-box;border:1px solid var(--vscode-editor-wordHighlightStrongBorder)}.monaco-editor.hc-black .wordHighlightStrong,.monaco-editor.hc-light .wordHighlightStrong{border-style:dotted}.monaco-editor .wordHighlightText{background-color:var(--vscode-editor-wordHighlightTextBackground);box-sizing:border-box;border:1px solid var(--vscode-editor-wordHighlightTextBorder)}.monaco-editor.hc-black .wordHighlightText,.monaco-editor.hc-light .wordHighlightText{border-style:dotted}.monaco-editor .zone-widget{position:absolute;z-index:10}.monaco-editor .zone-widget .zone-widget-container{border-top-style:solid;border-bottom-style:solid;border-top-width:0;border-bottom-width:0;position:relative}.monaco-editor .accessibilityHelpWidget{padding:10px;vertical-align:middle;overflow:scroll;color:var(--vscode-editorWidget-foreground);background-color:var(--vscode-editorWidget-background);box-shadow:0 2px 8px var(--vscode-widget-shadow);border:2px solid var(--vscode-contrastBorder)}.monaco-editor .iPadShowKeyboard{width:58px;min-width:0;height:36px;min-height:0;margin:0;padding:0;position:absolute;resize:none;overflow:hidden;background:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTMiIGhlaWdodD0iMzYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwKSI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00OC4wMzYgNC4wMUg0LjAwOFYzMi4wM2g0NC4wMjhWNC4wMXpNNC4wMDguMDA4QTQuMDAzIDQuMDAzIDAgMDAuMDA1IDQuMDFWMzIuMDNhNC4wMDMgNC4wMDMgMCAwMDQuMDAzIDQuMDAyaDQ0LjAyOGE0LjAwMyA0LjAwMyAwIDAwNC4wMDMtNC4wMDJWNC4wMUE0LjAwMyA0LjAwMyAwIDAwNDguMDM2LjAwOEg0LjAwOHpNOC4wMSA4LjAxM2g0LjAwM3Y0LjAwM0g4LjAxVjguMDEzem0xMi4wMDggMGgtNC4wMDJ2NC4wMDNoNC4wMDJWOC4wMTN6bTQuMDAzIDBoNC4wMDJ2NC4wMDNoLTQuMDAyVjguMDEzem0xMi4wMDggMGgtNC4wMDN2NC4wMDNoNC4wMDNWOC4wMTN6bTQuMDAyIDBoNC4wMDN2NC4wMDNINDAuMDNWOC4wMTN6bS0yNC4wMTUgOC4wMDVIOC4wMXY0LjAwM2g4LjAwNnYtNC4wMDN6bTQuMDAyIDBoNC4wMDN2NC4wMDNoLTQuMDAzdi00LjAwM3ptMTIuMDA4IDBoLTQuMDAzdjQuMDAzaDQuMDAzdi00LjAwM3ptMTIuMDA4IDB2NC4wMDNoLTguMDA1di00LjAwM2g4LjAwNXptLTMyLjAyMSA4LjAwNUg4LjAxdjQuMDAzaDQuMDAzdi00LjAwM3ptNC4wMDMgMGgyMC4wMTN2NC4wMDNIMTYuMDE2di00LjAwM3ptMjguMDE4IDBINDAuMDN2NC4wMDNoNC4wMDN2LTQuMDAzeiIgZmlsbD0iIzQyNDI0MiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwaDUzdjM2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+) 50% no-repeat;border:4px solid #f6f6f6;border-radius:4px}.monaco-editor.vs-dark .iPadShowKeyboard{background:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTMiIGhlaWdodD0iMzYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwKSI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00OC4wMzYgNC4wMUg0LjAwOFYzMi4wM2g0NC4wMjhWNC4wMXpNNC4wMDguMDA4QTQuMDAzIDQuMDAzIDAgMDAuMDA1IDQuMDFWMzIuMDNhNC4wMDMgNC4wMDMgMCAwMDQuMDAzIDQuMDAyaDQ0LjAyOGE0LjAwMyA0LjAwMyAwIDAwNC4wMDMtNC4wMDJWNC4wMUE0LjAwMyA0LjAwMyAwIDAwNDguMDM2LjAwOEg0LjAwOHpNOC4wMSA4LjAxM2g0LjAwM3Y0LjAwM0g4LjAxVjguMDEzem0xMi4wMDggMGgtNC4wMDJ2NC4wMDNoNC4wMDJWOC4wMTN6bTQuMDAzIDBoNC4wMDJ2NC4wMDNoLTQuMDAyVjguMDEzem0xMi4wMDggMGgtNC4wMDN2NC4wMDNoNC4wMDNWOC4wMTN6bTQuMDAyIDBoNC4wMDN2NC4wMDNINDAuMDNWOC4wMTN6bS0yNC4wMTUgOC4wMDVIOC4wMXY0LjAwM2g4LjAwNnYtNC4wMDN6bTQuMDAyIDBoNC4wMDN2NC4wMDNoLTQuMDAzdi00LjAwM3ptMTIuMDA4IDBoLTQuMDAzdjQuMDAzaDQuMDAzdi00LjAwM3ptMTIuMDA4IDB2NC4wMDNoLTguMDA1di00LjAwM2g4LjAwNXptLTMyLjAyMSA4LjAwNUg4LjAxdjQuMDAzaDQuMDAzdi00LjAwM3ptNC4wMDMgMGgyMC4wMTN2NC4wMDNIMTYuMDE2di00LjAwM3ptMjguMDE4IDBINDAuMDN2NC4wMDNoNC4wMDN2LTQuMDAzeiIgZmlsbD0iI0M1QzVDNSIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwaDUzdjM2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+) 50% no-repeat;border:4px solid #252526}.monaco-editor .tokens-inspect-widget{z-index:50;user-select:text;-webkit-user-select:text;padding:10px;color:var(--vscode-editorHoverWidget-foreground);background-color:var(--vscode-editorHoverWidget-background);border:1px solid var(--vscode-editorHoverWidget-border)}.monaco-editor.hc-black .tokens-inspect-widget,.monaco-editor.hc-light .tokens-inspect-widget{border-width:2px}.monaco-editor .tokens-inspect-widget .tokens-inspect-separator{height:1px;border:0;background-color:var(--vscode-editorHoverWidget-border)}.monaco-editor .tokens-inspect-widget .tm-token{font-family:var(--monaco-monospace-font)}.monaco-editor .tokens-inspect-widget .tm-token-length{font-weight:400;font-size:60%;float:right}.monaco-editor .tokens-inspect-widget .tm-metadata-table{width:100%}.monaco-editor .tokens-inspect-widget .tm-metadata-value{font-family:var(--monaco-monospace-font);text-align:right}.monaco-editor .tokens-inspect-widget .tm-token-type{font-family:var(--monaco-monospace-font)}.quick-input-widget{font-size:13px}.quick-input-widget .monaco-highlighted-label .highlight{color:#0066bf}.vs .quick-input-widget .monaco-list-row.focused .monaco-highlighted-label .highlight{color:#9dddff}.vs-dark .quick-input-widget .monaco-highlighted-label .highlight{color:#0097fb}.hc-black .quick-input-widget .monaco-highlighted-label .highlight{color:#f38518}.hc-light .quick-input-widget .monaco-highlighted-label .highlight{color:#0f4a85}.monaco-keybinding>.monaco-keybinding-key{background-color:hsla(0,0%,86.7%,.4);border:1px solid hsla(0,0%,80%,.4);border-bottom-color:hsla(0,0%,73.3%,.4);box-shadow:inset 0 -1px 0 hsla(0,0%,73.3%,.4);color:#555}.hc-black .monaco-keybinding>.monaco-keybinding-key{background-color:transparent;border:1px solid #6fc3df;box-shadow:none;color:#fff}.hc-light .monaco-keybinding>.monaco-keybinding-key{background-color:transparent;border:1px solid #0f4a85;box-shadow:none;color:#292929}.vs-dark .monaco-keybinding>.monaco-keybinding-key{background-color:hsla(0,0%,50.2%,.17);border:1px solid rgba(51,51,51,.6);border-bottom-color:rgba(68,68,68,.6);box-shadow:inset 0 -1px 0 rgba(68,68,68,.6);color:#ccc}.monaco-editor{font-family:-apple-system,BlinkMacSystemFont,Segoe WPC,Segoe UI,HelveticaNeue-Light,system-ui,Ubuntu,Droid Sans,sans-serif;--monaco-monospace-font:"SF Mono",Monaco,Menlo,Consolas,"Ubuntu Mono","Liberation Mono","DejaVu Sans Mono","Courier New",monospace}.monaco-editor.hc-black .monaco-menu .monaco-action-bar.vertical .action-menu-item:focus .action-label,.monaco-editor.hc-light .monaco-menu .monaco-action-bar.vertical .action-menu-item:focus .action-label,.monaco-editor.vs-dark .monaco-menu .monaco-action-bar.vertical .action-menu-item:focus .action-label,.monaco-menu .monaco-action-bar.vertical .action-item .action-menu-item:focus .action-label{stroke-width:1.2px}.monaco-hover p{margin:0}.monaco-aria-container{position:absolute!important;top:0;height:1px;width:1px;margin:-1px;overflow:hidden;padding:0;clip:rect(1px,1px,1px,1px);clip-path:inset(50%)}.action-widget{font-size:13px;border-radius:0;min-width:160px;max-width:500px;z-index:40;display:block;width:100%;border:1px solid var(--vscode-editorWidget-border)!important;background-color:var(--vscode-editorWidget-background);color:var(--vscode-editorWidget-foreground)}.context-view-block{z-index:-1}.context-view-block,.context-view-pointerBlock{position:fixed;cursor:auto;left:0;top:0;width:100%;height:100%}.context-view-pointerBlock{z-index:2}.action-widget .monaco-list{user-select:none;-webkit-user-select:none;border:0!important}.action-widget .monaco-list:focus:before{outline:0!important}.action-widget .monaco-list .monaco-scrollable-element{overflow:visible}.action-widget .monaco-list .monaco-list-row{padding:0 10px;white-space:nowrap;cursor:pointer;touch-action:none;width:100%}.action-widget .monaco-list .monaco-list-row.action.focused:not(.option-disabled){background-color:var(--vscode-quickInputList-focusBackground)!important;color:var(--vscode-quickInputList-focusForeground);outline:1px solid var(--vscode-menu-selectionBorder,transparent);outline-offset:-1px}.action-widget .monaco-list-row.group-header{color:var(--vscode-pickerGroup-foreground)!important;font-weight:600}.action-widget .monaco-list .group-header,.action-widget .monaco-list .option-disabled,.action-widget .monaco-list .option-disabled .focused,.action-widget .monaco-list .option-disabled .focused:before,.action-widget .monaco-list .option-disabled:before{cursor:default!important;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;background-color:transparent!important;outline:0 solid!important}.action-widget .monaco-list-row.action{display:flex;gap:6px;align-items:center}.action-widget .monaco-list-row.action.option-disabled{color:var(--vscode-disabledForeground)}.action-widget .monaco-list-row.action.option-disabled .codicon{opacity:.4}.action-widget .monaco-list-row.action:not(.option-disabled) .codicon{color:inherit}.action-widget .monaco-list-row.action .title{flex:1;overflow:hidden;text-overflow:ellipsis}.action-widget .action-widget-action-bar{background-color:var(--vscode-editorHoverWidget-statusBarBackground);border-top:1px solid var(--vscode-editorHoverWidget-border)}.action-widget .action-widget-action-bar:before{display:block;content:"";width:100%}.action-widget .action-widget-action-bar .actions-container{padding:0 8px}.action-widget-action-bar .action-label{color:var(--vscode-textLink-activeForeground);font-size:12px;line-height:22px;padding:0;pointer-events:all}.action-widget-action-bar .action-item{margin-right:16px;pointer-events:none}.action-widget-action-bar .action-label:hover{background-color:transparent!important}.monaco-action-bar .action-item.menu-entry .action-label.icon{width:16px;height:16px;background-repeat:no-repeat;background-position:50%;background-size:16px}.monaco-dropdown-with-default{display:flex!important;flex-direction:row;border-radius:5px}.monaco-dropdown-with-default>.action-container>.action-label{margin-right:0}.monaco-dropdown-with-default>.action-container.menu-entry>.action-label.icon{width:16px;height:16px;background-repeat:no-repeat;background-position:50%;background-size:16px}.monaco-dropdown-with-default>.dropdown-action-container>.monaco-dropdown>.dropdown-label .codicon[class*=codicon-]{font-size:12px;padding-left:0;padding-right:0;line-height:16px;margin-left:-3px}.monaco-dropdown-with-default>.dropdown-action-container>.monaco-dropdown>.dropdown-label>.action-label{display:block;background-size:16px;background-position:50%;background-repeat:no-repeat}.monaco-link{color:var(--vscode-textLink-foreground)}.monaco-link:hover{color:var(--vscode-textLink-activeForeground)}.quick-input-widget{position:absolute;width:600px;z-index:2550;left:50%;margin-left:-300px;-webkit-app-region:no-drag;border-radius:6px}.quick-input-titlebar{display:flex;align-items:center;border-top-left-radius:5px;border-top-right-radius:5px}.quick-input-left-action-bar{display:flex;margin-left:4px;flex:1}.quick-input-title{padding:3px 0;text-align:center;text-overflow:ellipsis;overflow:hidden}.quick-input-right-action-bar{display:flex;margin-right:4px;flex:1}.quick-input-right-action-bar>.actions-container{justify-content:flex-end}.quick-input-titlebar .monaco-action-bar .action-label.codicon{background-position:50%;background-repeat:no-repeat;padding:2px}.quick-input-description{margin:6px}.quick-input-header .quick-input-description{margin:4px 2px}.quick-input-header{display:flex;padding:8px 6px 6px}.quick-input-widget.hidden-input .quick-input-header{padding:0;margin-bottom:0}.quick-input-and-message{display:flex;flex-direction:column;flex-grow:1;min-width:0;position:relative}.quick-input-check-all{align-self:center;margin:0}.quick-input-filter{flex-grow:1;display:flex;position:relative}.quick-input-box{flex-grow:1}.quick-input-widget.show-checkboxes .quick-input-box,.quick-input-widget.show-checkboxes .quick-input-message{margin-left:5px}.quick-input-visible-count{position:absolute;left:-10000px}.quick-input-count{align-self:center;position:absolute;right:4px;display:flex;align-items:center}.quick-input-count .monaco-count-badge{vertical-align:middle;padding:2px 4px;border-radius:2px;min-height:auto;line-height:normal}.quick-input-action{margin-left:6px}.quick-input-action .monaco-text-button{font-size:11px;padding:0 6px;display:flex;height:25px;align-items:center}.quick-input-message{margin-top:-1px;padding:5px;overflow-wrap:break-word}.quick-input-message>.codicon{margin:0 .2em;vertical-align:text-bottom}.quick-input-message a{color:inherit}.quick-input-progress.monaco-progress-container{position:relative}.quick-input-progress.monaco-progress-container,.quick-input-progress.monaco-progress-container .progress-bit{height:2px}.quick-input-list{line-height:22px}.quick-input-widget.hidden-input .quick-input-list{margin-top:4px;padding-bottom:4px}.quick-input-list .monaco-list{overflow:hidden;max-height:440px;padding-bottom:5px}.quick-input-list .monaco-scrollable-element{padding:0 5px}.quick-input-list .quick-input-list-entry{box-sizing:border-box;overflow:hidden;display:flex;height:100%;padding:0 6px}.quick-input-list .quick-input-list-entry.quick-input-list-separator-border{border-top-width:1px;border-top-style:solid}.quick-input-list .monaco-list-row{border-radius:3px}.quick-input-list .monaco-list-row[data-index="0"] .quick-input-list-entry.quick-input-list-separator-border{border-top-style:none}.quick-input-list .quick-input-list-label{overflow:hidden;display:flex;height:100%;flex:1}.quick-input-list .quick-input-list-checkbox{align-self:center;margin:0}.quick-input-list .quick-input-list-rows{overflow:hidden;text-overflow:ellipsis;display:flex;flex-direction:column;height:100%;flex:1;margin-left:5px}.quick-input-widget.show-checkboxes .quick-input-list .quick-input-list-rows{margin-left:10px}.quick-input-widget .quick-input-list .quick-input-list-checkbox{display:none}.quick-input-widget.show-checkboxes .quick-input-list .quick-input-list-checkbox{display:inline}.quick-input-list .quick-input-list-rows>.quick-input-list-row{display:flex;align-items:center}.quick-input-list .quick-input-list-rows>.quick-input-list-row .monaco-icon-label,.quick-input-list .quick-input-list-rows>.quick-input-list-row .monaco-icon-label .monaco-icon-label-container>.monaco-icon-name-container{flex:1}.quick-input-list .quick-input-list-rows>.quick-input-list-row .codicon[class*=codicon-]{vertical-align:text-bottom}.quick-input-list .quick-input-list-rows .monaco-highlighted-label>span{opacity:1}.quick-input-list .quick-input-list-entry .quick-input-list-entry-keybinding{margin-right:8px}.quick-input-list .quick-input-list-label-meta{opacity:.7;line-height:normal;text-overflow:ellipsis;overflow:hidden}.quick-input-list .monaco-highlighted-label .highlight{font-weight:700}.quick-input-list .quick-input-list-entry .quick-input-list-separator{margin-right:4px}.quick-input-list .quick-input-list-entry-action-bar{display:flex;flex:0;overflow:visible}.quick-input-list .quick-input-list-entry-action-bar .action-label{display:none}.quick-input-list .quick-input-list-entry-action-bar .action-label.codicon{margin-right:4px;padding:0 2px 2px}.quick-input-list .quick-input-list-entry-action-bar{margin-top:1px;margin-right:4px}.quick-input-list .monaco-list-row.focused .quick-input-list-entry-action-bar .action-label,.quick-input-list .quick-input-list-entry .quick-input-list-entry-action-bar .action-label.always-visible,.quick-input-list .quick-input-list-entry:hover .quick-input-list-entry-action-bar .action-label{display:flex}.quick-input-list .monaco-list-row.focused .monaco-keybinding-key,.quick-input-list .monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator{color:inherit}.quick-input-list .monaco-list-row.focused .monaco-keybinding-key{background:none}.extension-editor .codicon.codicon-error,.extensions-viewlet>.extensions .codicon.codicon-error,.markers-panel .marker-icon .codicon.codicon-error,.markers-panel .marker-icon.error,.monaco-editor .zone-widget .codicon.codicon-error,.preferences-editor .codicon.codicon-error,.text-search-provider-messages .providerMessage .codicon.codicon-error{color:var(--vscode-problemsErrorIcon-foreground)}.extension-editor .codicon.codicon-warning,.extensions-viewlet>.extensions .codicon.codicon-warning,.markers-panel .marker-icon .codicon.codicon-warning,.markers-panel .marker-icon.warning,.monaco-editor .zone-widget .codicon.codicon-warning,.preferences-editor .codicon.codicon-warning,.text-search-provider-messages .providerMessage .codicon.codicon-warning{color:var(--vscode-problemsWarningIcon-foreground)}.extension-editor .codicon.codicon-info,.extensions-viewlet>.extensions .codicon.codicon-info,.markers-panel .marker-icon .codicon.codicon-info,.markers-panel .marker-icon.info,.monaco-editor .zone-widget .codicon.codicon-info,.preferences-editor .codicon.codicon-info,.text-search-provider-messages .providerMessage .codicon.codicon-info{color:var(--vscode-problemsInfoIcon-foreground)} \ No newline at end of file diff --git a/apps/studio/public/monaco-editor/editor/editor.main.js b/apps/studio/public/monaco-editor/editor/editor.main.js deleted file mode 100644 index 6babd5027e6..00000000000 --- a/apps/studio/public/monaco-editor/editor/editor.main.js +++ /dev/null @@ -1,763 +0,0 @@ -/*!----------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Version: 0.37.0(20c3f8ff1e9176c954b34c052b2a84358d41a030) - * Released under the MIT license - * https://github.com/microsoft/vscode/blob/main/LICENSE.txt -*-----------------------------------------------------------*/(function(){var X=["exports","require","vs/base/common/lifecycle","vs/nls","vs/nls!vs/editor/editor.main","vs/editor/common/core/range","vs/base/common/event","vs/base/browser/dom","vs/platform/instantiation/common/instantiation","vs/base/common/strings","vs/css!vs/editor/editor.main","vs/base/common/errors","vs/base/common/async","vs/editor/common/core/position","vs/platform/contextkey/common/contextkey","vs/editor/browser/editorExtensions","vs/base/common/arrays","vs/base/common/platform","vs/base/common/types","vs/editor/common/services/languageFeatures","vs/base/common/uri","vs/base/common/cancellation","vs/editor/common/editorContextKeys","vs/editor/common/core/selection","vs/platform/theme/common/themeService","vs/platform/commands/common/commands","vs/editor/common/languages","vs/platform/configuration/common/configuration","vs/platform/actions/common/actions","vs/base/common/codicons","vs/editor/browser/services/codeEditorService","vs/platform/theme/common/colorRegistry","vs/base/common/themables","vs/editor/common/languages/languageConfigurationRegistry","vs/platform/registry/common/platform","vs/base/browser/fastDomNode","vs/platform/keybinding/common/keybinding","vs/base/common/color","vs/editor/common/languages/language","vs/editor/common/model/textModel","vs/editor/common/config/editorOptions","vs/base/common/actions","vs/base/common/resources","vs/platform/notification/common/notification","vs/base/browser/keyboardEvent","vs/base/common/objects","vs/base/browser/browser","vs/platform/opener/common/opener","vs/editor/common/model","vs/base/common/network","vs/base/browser/ui/aria/aria","vs/platform/instantiation/common/extensions","vs/editor/browser/view/viewPart","vs/editor/common/services/model","vs/base/common/iterator","vs/base/common/map","vs/base/browser/touch","vs/platform/contextview/browser/contextView","vs/base/common/keyCodes","vs/base/common/linkedList","vs/base/common/stopwatch","vs/editor/common/services/resolverService","vs/platform/log/common/log","vs/base/common/filters","vs/editor/common/core/editOperation","vs/editor/common/cursorCommon","vs/base/browser/mouseEvent","vs/base/browser/ui/widget","vs/base/common/htmlContent","vs/base/browser/ui/actionbar/actionbar","vs/editor/common/services/languageFeatureDebounce","vs/platform/progress/common/progress","vs/editor/common/languages/modesRegistry","vs/editor/common/core/editorColorRegistry","vs/editor/browser/config/domFontInfo","vs/editor/common/core/cursorColumns","vs/base/browser/event","vs/base/browser/ui/scrollbar/scrollableElement","vs/platform/accessibility/common/accessibility","vs/platform/quickinput/common/quickInput","vs/platform/telemetry/common/telemetry","vs/platform/theme/common/iconRegistry","vs/editor/common/viewModel","vs/editor/common/standaloneStrings","vs/platform/storage/common/storage","vs/base/common/assert","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length","vs/editor/common/tokens/lineTokens","vs/base/common/path","vs/platform/markers/common/markers","vs/platform/configuration/common/configurationRegistry","vs/platform/theme/common/theme","vs/base/common/functional","vs/editor/contrib/hover/browser/hoverTypes","vs/editor/contrib/snippet/browser/snippetParser","vs/editor/common/core/stringBuilder","vs/platform/clipboard/common/clipboardService","vs/editor/contrib/editorState/browser/editorState","vs/platform/theme/browser/defaultStyles","vs/base/common/decorators","vs/base/common/lazy","vs/base/common/mime","vs/base/common/severity","vs/editor/common/core/lineRange","vs/editor/common/languages/languageConfiguration","vs/editor/common/textModelEvents","vs/editor/browser/view/dynamicViewOverlay","vs/editor/contrib/codeAction/common/types","vs/base/browser/ui/list/listWidget","vs/editor/common/viewLayout/viewLineRenderer","vs/editor/common/services/editorWorker","vs/platform/keybinding/common/keybindingsRegistry","vs/base/common/keybindings","vs/base/common/iconLabels","vs/editor/browser/stableEditorScroll","vs/editor/common/core/characterClassifier","vs/editor/common/core/eolCounter","vs/editor/common/core/offsetRange","vs/editor/common/commands/replaceCommand","vs/editor/common/encodedTokenAttributes","vs/editor/common/languages/supports","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/smallImmutableSet","vs/editor/standalone/common/standaloneTheme","vs/platform/layout/browser/layoutService","vs/editor/contrib/markdownRenderer/browser/markdownRenderer","vs/editor/contrib/suggest/browser/suggest","vs/platform/quickinput/common/quickAccess","vs/editor/contrib/codeAction/browser/codeAction","vs/editor/contrib/peekView/browser/peekView","vs/base/browser/ui/tree/tree","vs/base/common/buffer","vs/base/browser/dnd","vs/base/common/numbers","vs/base/common/hash","vs/editor/browser/view/renderingContext","vs/editor/common/core/wordCharacterClassifier","vs/editor/common/core/wordHelper","vs/editor/common/viewEventHandler","vs/editor/common/viewLayout/lineDecorations","vs/base/browser/globalPointerMoveMonitor","vs/base/browser/ui/iconLabel/iconLabels","vs/base/browser/ui/sash/sash","vs/base/browser/ui/toggle/toggle","vs/base/browser/ui/actionbar/actionViewItems","vs/editor/common/config/editorZoom","vs/editor/common/languages/nullTokenize","vs/editor/contrib/gotoSymbol/browser/referencesModel","vs/editor/browser/services/bulkEditService","vs/platform/dialogs/common/dialogs","vs/platform/label/common/label","vs/editor/contrib/message/browser/messageController","vs/editor/browser/editorDom","vs/platform/actions/browser/menuEntryActionViewItem","vs/editor/browser/widget/embeddedCodeEditorWidget","vs/platform/workspace/common/workspace","vs/base/common/idGenerator","vs/base/common/range","vs/base/common/diff/diff","vs/base/common/uint","vs/base/common/uuid","vs/base/browser/ui/codicons/codiconStyles","vs/base/browser/ui/mouseCursor/mouseCursor","vs/css!vs/platform/quickinput/browser/media/quickInput","vs/editor/common/core/textModelDefaults","vs/editor/common/diff/algorithms/diffAlgorithm","vs/editor/common/diff/linesDiffComputer","vs/editor/common/editorCommon","vs/editor/browser/editorBrowser","vs/editor/common/cursor/cursorWordOperations","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/ast","vs/editor/common/model/textModelSearch","vs/editor/contrib/folding/browser/foldingRanges","vs/base/common/scrollable","vs/base/browser/ui/tree/abstractTree","vs/editor/contrib/gotoSymbol/browser/link/clickLinkGesture","vs/editor/contrib/inlineCompletions/browser/ghostText","vs/editor/common/services/textResourceConfiguration","vs/platform/audioCues/browser/audioCueService","vs/platform/instantiation/common/serviceCollection","vs/editor/contrib/documentSymbols/browser/outlineModel","vs/editor/browser/coreCommands","vs/platform/list/browser/listService","vs/platform/undoRedo/common/undoRedo","vs/editor/browser/widget/codeEditorWidget","vs/editor/contrib/find/browser/findModel","vs/base/common/observableImpl/logging","vs/base/common/ternarySearchTree","vs/base/common/dataTransfer","vs/editor/common/core/indentation","vs/editor/common/cursor/cursorMoveOperations","vs/editor/common/cursor/cursorDeleteOperations","vs/editor/common/cursor/cursorMoveCommands","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/beforeEditPositionMapper","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/tokenizer","vs/editor/common/model/utils","vs/editor/common/standalone/standaloneEnums","vs/editor/common/textModelGuides","vs/editor/browser/viewParts/glyphMargin/glyphMargin","vs/editor/common/viewEvents","vs/editor/contrib/inlineCompletions/browser/consts","vs/editor/contrib/inlineCompletions/browser/utils","vs/editor/contrib/stickyScroll/browser/stickyScrollElement","vs/base/common/keybindingLabels","vs/base/common/extpath","vs/base/browser/canIUse","vs/base/browser/ui/tree/indexTreeModel","vs/base/browser/ui/tree/objectTreeModel","vs/base/common/marshalling","vs/base/browser/formattedTextRenderer","vs/base/browser/ui/keybindingLabel/keybindingLabel","vs/base/browser/ui/scrollbar/scrollbarArrow","vs/base/browser/ui/list/listView","vs/base/browser/markdownRenderer","vs/base/browser/ui/iconLabel/iconLabel","vs/base/browser/ui/inputbox/inputBox","vs/base/browser/ui/findinput/findInput","vs/editor/browser/config/tabFocus","vs/editor/browser/controller/textAreaInput","vs/editor/browser/view/viewLayer","vs/editor/common/languages/supports/richEditBrackets","vs/editor/common/viewModelEventDispatcher","vs/editor/contrib/inlineCompletions/browser/inlineCompletionToGhostText","vs/editor/common/config/fontInfo","vs/platform/instantiation/common/descriptors","vs/editor/common/services/markerDecorations","vs/editor/common/services/semanticTokensStyling","vs/editor/contrib/parameterHints/browser/provideSignatureHelp","vs/platform/jsonschemas/common/jsonContributionRegistry","vs/editor/common/config/editorConfigurationSchema","vs/editor/common/languages/autoIndent","vs/editor/common/languages/enterAction","vs/editor/common/commands/shiftCommand","vs/editor/common/cursor/cursorTypeOperations","vs/editor/contrib/gotoSymbol/browser/goToSymbol","vs/editor/contrib/hover/browser/markdownHoverParticipant","vs/editor/contrib/symbolIcons/browser/symbolIcons","vs/editor/contrib/codeAction/browser/codeActionController","vs/editor/common/services/semanticTokensProviderStyling","vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget","vs/editor/contrib/gotoSymbol/browser/goToCommands","vs/editor/contrib/snippet/browser/snippetController2","vs/editor/standalone/browser/standaloneServices","vs/base/browser/performance","vs/base/browser/ui/scrollbar/scrollbarState","vs/base/common/cache","vs/base/common/observable","vs/base/common/symbols","vs/css!vs/base/browser/ui/actionbar/actionbar","vs/css!vs/base/browser/ui/dropdown/dropdown","vs/css!vs/base/browser/ui/findinput/findInput","vs/css!vs/base/browser/ui/list/list","vs/css!vs/platform/actionWidget/browser/actionWidget","vs/editor/browser/view/viewUserInputEvents","vs/editor/browser/viewParts/minimap/minimapCharSheet","vs/editor/browser/controller/textAreaState","vs/editor/common/core/rgba","vs/editor/common/cursor/cursorAtomicMoveOperations","vs/editor/common/editorAction","vs/editor/common/editorFeatures","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/parser","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/brackets","vs/editor/common/model/prefixSumComputer","vs/editor/common/model/textModelPart","vs/editor/common/model/pieceTreeTextBuffer/pieceTreeBase","vs/editor/common/modelLineProjectionData","vs/editor/common/services/unicodeTextModelHighlighter","vs/editor/common/model/guidesTextModelPart","vs/editor/common/tokens/contiguousMultilineTokensBuilder","vs/editor/browser/viewParts/margin/margin","vs/editor/common/viewModel/overviewZoneManager","vs/editor/contrib/comment/browser/blockCommentCommand","vs/editor/contrib/folding/browser/indentRangeProvider","vs/editor/contrib/folding/browser/syntaxRangeProvider","vs/editor/contrib/format/browser/formattingEdit","vs/editor/contrib/indentation/browser/indentUtils","vs/editor/contrib/semanticTokens/common/semanticTokensConfig","vs/editor/contrib/smartSelect/browser/bracketSelections","vs/editor/contrib/suggest/browser/completionModel","vs/editor/contrib/suggest/browser/wordDistance","vs/editor/standalone/common/monarch/monarchCommon","vs/base/common/glob","vs/base/common/ime","vs/base/browser/dompurify/dompurify","vs/base/browser/ui/contextview/contextview","vs/base/browser/ui/countBadge/countBadge","vs/base/browser/ui/highlightedlabel/highlightedLabel","vs/base/browser/ui/resizable/resizable","vs/base/browser/ui/scrollbar/abstractScrollbar","vs/base/browser/ui/hover/hoverWidget","vs/base/browser/ui/splitview/splitview","vs/base/browser/ui/findinput/findInputToggles","vs/base/browser/ui/iconLabel/iconLabelHover","vs/base/browser/ui/dropdown/dropdownActionViewItem","vs/base/browser/ui/tree/objectTree","vs/base/common/worker/simpleWorker","vs/editor/browser/config/elementSizeObserver","vs/editor/common/core/textChange","vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer","vs/editor/common/languages/textToHtmlTokenizer","vs/editor/common/services/editorBaseApi","vs/editor/common/viewModel/minimapTokensColorTracker","vs/editor/contrib/hover/browser/hoverOperation","vs/editor/contrib/inlayHints/browser/inlayHints","vs/editor/browser/config/fontMeasurements","vs/editor/common/viewModel/viewModelDecorations","vs/editor/common/model/editStack","vs/platform/files/common/files","vs/editor/contrib/codelens/browser/codelens","vs/editor/contrib/colorPicker/browser/color","vs/editor/contrib/semanticTokens/common/getSemanticTokens","vs/editor/standalone/common/monarch/monarchLexer","vs/editor/browser/widget/diffNavigator","vs/platform/contextkey/common/contextkeys","vs/platform/keybinding/common/resolvedKeybindingItem","vs/editor/standalone/browser/standaloneLayoutService","vs/platform/quickinput/browser/quickInputUtils","vs/editor/browser/dnd","vs/editor/browser/services/editorWorkerService","vs/editor/contrib/suggest/browser/suggestWidgetDetails","vs/platform/configuration/common/configurationModels","vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode","vs/platform/history/browser/contextScopedHistoryWidget","vs/editor/contrib/suggest/browser/suggestMemory","vs/editor/contrib/codeAction/browser/codeActionModel","vs/editor/contrib/codeAction/browser/lightBulbWidget","vs/editor/contrib/format/browser/format","vs/editor/contrib/hover/browser/getHover","vs/editor/contrib/wordOperations/browser/wordOperations","vs/editor/browser/viewParts/lines/viewLine","vs/editor/browser/controller/mouseTarget","vs/editor/browser/viewParts/lineNumbers/lineNumbers","vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess","vs/editor/standalone/browser/standaloneCodeEditorService","vs/editor/standalone/browser/standaloneThemeService","vs/editor/browser/widget/diffEditorWidget","vs/editor/contrib/colorPicker/browser/colorDetector","vs/editor/contrib/find/browser/findController","vs/editor/contrib/folding/browser/folding","vs/editor/contrib/wordHighlighter/browser/highlightDecorations","vs/editor/contrib/gotoError/browser/gotoError","vs/editor/contrib/gotoSymbol/browser/peek/referencesController","vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition","vs/editor/contrib/hover/browser/hover","vs/editor/contrib/inlayHints/browser/inlayHintsLocations","vs/editor/contrib/inlayHints/browser/inlayHintsController","vs/editor/contrib/stickyScroll/browser/stickyScrollController","vs/editor/contrib/snippet/browser/snippetSession","vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel","vs/editor/contrib/suggest/browser/suggestController","vs/editor/contrib/inlineCompletions/browser/ghostTextController","vs/platform/workspace/common/workspaceTrust","vs/base/browser/iframe","vs/base/browser/ui/list/list","vs/base/browser/ui/list/splice","vs/base/common/collections","vs/base/common/diff/diffChange","vs/base/common/comparers","vs/base/common/linkedText","vs/base/common/marked/marked","vs/base/common/navigator","vs/base/common/history","vs/base/common/observableImpl/autorun","vs/base/common/observableImpl/base","vs/base/common/observableImpl/utils","vs/base/browser/ui/list/rangeMap","vs/base/common/search","vs/css!vs/base/browser/ui/aria/aria","vs/css!vs/base/browser/ui/button/button","vs/css!vs/base/browser/ui/codicons/codicon/codicon","vs/css!vs/base/browser/ui/codicons/codicon/codicon-modifiers","vs/css!vs/base/browser/ui/contextview/contextview","vs/css!vs/base/browser/ui/countBadge/countBadge","vs/css!vs/base/browser/ui/hover/hover","vs/css!vs/base/browser/ui/iconLabel/iconlabel","vs/css!vs/base/browser/ui/inputbox/inputBox","vs/css!vs/base/browser/ui/keybindingLabel/keybindingLabel","vs/css!vs/base/browser/ui/mouseCursor/mouseCursor","vs/css!vs/base/browser/ui/progressbar/progressbar","vs/css!vs/base/browser/ui/sash/sash","vs/css!vs/base/browser/ui/scrollbar/media/scrollbars","vs/css!vs/base/browser/ui/selectBox/selectBox","vs/css!vs/base/browser/ui/selectBox/selectBoxCustom","vs/css!vs/base/browser/ui/splitview/splitview","vs/css!vs/base/browser/ui/table/table","vs/css!vs/base/browser/ui/toggle/toggle","vs/css!vs/base/browser/ui/toolbar/toolbar","vs/css!vs/base/browser/ui/tree/media/tree","vs/css!vs/editor/browser/controller/textAreaHandler","vs/css!vs/editor/browser/viewParts/blockDecorations/blockDecorations","vs/css!vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight","vs/css!vs/editor/browser/viewParts/decorations/decorations","vs/css!vs/editor/browser/viewParts/glyphMargin/glyphMargin","vs/css!vs/editor/browser/viewParts/indentGuides/indentGuides","vs/css!vs/editor/browser/viewParts/lineNumbers/lineNumbers","vs/css!vs/editor/browser/viewParts/lines/viewLines","vs/css!vs/editor/browser/viewParts/linesDecorations/linesDecorations","vs/css!vs/editor/browser/viewParts/margin/margin","vs/css!vs/editor/browser/viewParts/marginDecorations/marginDecorations","vs/css!vs/editor/browser/viewParts/minimap/minimap","vs/css!vs/editor/browser/viewParts/overlayWidgets/overlayWidgets","vs/css!vs/editor/browser/viewParts/rulers/rulers","vs/css!vs/editor/browser/viewParts/scrollDecoration/scrollDecoration","vs/css!vs/editor/browser/viewParts/selections/selections","vs/css!vs/editor/browser/viewParts/viewCursors/viewCursors","vs/css!vs/editor/browser/viewParts/whitespace/whitespace","vs/css!vs/editor/browser/widget/media/diffEditor","vs/css!vs/editor/browser/widget/media/diffReview","vs/css!vs/editor/browser/widget/media/editor","vs/css!vs/editor/contrib/anchorSelect/browser/anchorSelect","vs/css!vs/editor/contrib/bracketMatching/browser/bracketMatching","vs/css!vs/editor/contrib/codeAction/browser/lightBulbWidget","vs/css!vs/editor/contrib/codelens/browser/codelensWidget","vs/css!vs/editor/contrib/colorPicker/browser/colorPicker","vs/css!vs/editor/contrib/dnd/browser/dnd","vs/css!vs/editor/contrib/find/browser/findOptionsWidget","vs/css!vs/editor/contrib/find/browser/findWidget","vs/css!vs/editor/contrib/folding/browser/folding","vs/css!vs/editor/contrib/gotoError/browser/media/gotoErrorWidget","vs/css!vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition","vs/css!vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget","vs/css!vs/editor/contrib/hover/browser/hover","vs/css!vs/editor/contrib/inPlaceReplace/browser/inPlaceReplace","vs/css!vs/editor/contrib/inlineCompletions/browser/ghostText","vs/css!vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget","vs/css!vs/editor/contrib/linkedEditing/browser/linkedEditing","vs/css!vs/editor/contrib/links/browser/links","vs/css!vs/editor/contrib/markdownRenderer/browser/renderedMarkdown","vs/css!vs/editor/contrib/message/browser/messageController","vs/css!vs/editor/contrib/parameterHints/browser/parameterHints","vs/css!vs/editor/contrib/peekView/browser/media/peekViewWidget","vs/css!vs/editor/contrib/rename/browser/renameInputField","vs/css!vs/editor/contrib/snippet/browser/snippetSession","vs/css!vs/editor/contrib/stickyScroll/browser/stickyScroll","vs/css!vs/editor/contrib/suggest/browser/media/suggest","vs/css!vs/editor/contrib/symbolIcons/browser/symbolIcons","vs/css!vs/editor/contrib/unicodeHighlighter/browser/bannerController","vs/css!vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter","vs/css!vs/editor/contrib/wordHighlighter/browser/highlightDecorations","vs/css!vs/editor/contrib/zoneWidget/browser/zoneWidget","vs/css!vs/editor/standalone/browser/accessibilityHelp/accessibilityHelp","vs/css!vs/editor/standalone/browser/iPadShowKeyboard/iPadShowKeyboard","vs/css!vs/editor/standalone/browser/inspectTokens/inspectTokens","vs/css!vs/editor/standalone/browser/quickInput/standaloneQuickInput","vs/css!vs/editor/standalone/browser/standalone-tokens","vs/css!vs/platform/actions/browser/menuEntryActionViewItem","vs/css!vs/platform/opener/browser/link","vs/css!vs/platform/severityIcon/browser/media/severityIcon","vs/editor/browser/config/charWidthReader","vs/editor/browser/config/migrateOptions","vs/editor/browser/viewParts/lines/domReadingContext","vs/editor/browser/viewParts/lines/rangeUtil","vs/editor/browser/viewParts/minimap/minimapCharRenderer","vs/editor/browser/viewParts/minimap/minimapPreBaked","vs/editor/browser/viewParts/minimap/minimapCharRendererFactory","vs/editor/common/commands/trimTrailingWhitespaceCommand","vs/editor/common/commands/surroundSelectionCommand","vs/editor/common/cursor/cursorContext","vs/editor/common/diff/algorithms/joinSequenceDiffs","vs/editor/common/diff/algorithms/myersDiffAlgorithm","vs/editor/common/diff/algorithms/utils","vs/editor/common/diff/algorithms/dynamicProgrammingDiffing","vs/editor/common/diff/smartLinesDiffComputer","vs/editor/common/diff/standardLinesDiffComputer","vs/editor/common/diff/linesDiffComputers","vs/editor/common/editorTheme","vs/editor/common/languages/linkComputer","vs/editor/common/cursor/cursorColumnSelection","vs/editor/common/cursor/oneCursor","vs/editor/common/cursor/cursorCollection","vs/editor/common/languages/supports/characterPair","vs/editor/common/languages/supports/indentRules","vs/editor/common/languages/supports/inplaceReplaceSupport","vs/editor/common/languages/supports/languageBracketsConfiguration","vs/editor/common/languages/supports/onEnter","vs/editor/common/languages/supports/tokenization","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/combineTextEditInfos","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/nodeReader","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/concat23Trees","vs/editor/common/model/bracketPairsTextModelPart/fixBrackets","vs/editor/common/model/fixedArray","vs/editor/common/model/indentationGuesser","vs/editor/common/model/intervalTree","vs/editor/common/model/pieceTreeTextBuffer/rbTreeBase","vs/editor/common/model/mirrorTextModel","vs/editor/common/textModelBracketPairs","vs/editor/common/tokens/contiguousMultilineTokens","vs/editor/common/tokens/contiguousTokensEditing","vs/editor/common/tokens/contiguousTokensStore","vs/editor/common/tokens/sparseMultilineTokens","vs/editor/common/tokens/sparseTokensStore","vs/editor/browser/viewParts/blockDecorations/blockDecorations","vs/editor/browser/viewParts/decorations/decorations","vs/editor/browser/viewParts/linesDecorations/linesDecorations","vs/editor/browser/viewParts/marginDecorations/marginDecorations","vs/editor/browser/viewParts/overlayWidgets/overlayWidgets","vs/editor/browser/viewParts/rulers/rulers","vs/editor/browser/viewParts/scrollDecoration/scrollDecoration","vs/editor/browser/viewParts/viewZones/viewZones","vs/editor/common/viewLayout/linePart","vs/editor/common/viewLayout/linesLayout","vs/editor/common/viewLayout/viewLinesViewportData","vs/editor/common/viewModel/modelLineProjection","vs/editor/common/viewModel/monospaceLineBreaksComputer","vs/editor/browser/viewParts/overviewRuler/overviewRuler","vs/editor/common/viewModel/viewContext","vs/editor/contrib/caretOperations/browser/moveCaretCommand","vs/editor/contrib/comment/browser/lineCommentCommand","vs/editor/contrib/dnd/browser/dragAndDropCommand","vs/editor/contrib/find/browser/replaceAllCommand","vs/editor/contrib/find/browser/replacePattern","vs/editor/contrib/inPlaceReplace/browser/inPlaceReplaceCommand","vs/editor/contrib/linesOperations/browser/copyLinesCommand","vs/editor/contrib/linesOperations/browser/sortLinesCommand","vs/editor/contrib/smartSelect/browser/wordSelections","vs/editor/contrib/suggest/browser/suggestCommitCharacters","vs/editor/contrib/suggest/browser/suggestOvertypingCapturer","vs/editor/standalone/common/monarch/monarchCompile","vs/nls!vs/base/browser/ui/actionbar/actionViewItems","vs/nls!vs/base/browser/ui/findinput/findInput","vs/nls!vs/base/browser/ui/findinput/findInputToggles","vs/nls!vs/base/browser/ui/findinput/replaceInput","vs/nls!vs/base/browser/ui/iconLabel/iconLabelHover","vs/nls!vs/base/browser/ui/inputbox/inputBox","vs/nls!vs/base/browser/ui/keybindingLabel/keybindingLabel","vs/nls!vs/base/browser/ui/selectBox/selectBoxCustom","vs/nls!vs/base/browser/ui/toolbar/toolbar","vs/nls!vs/base/browser/ui/tree/abstractTree","vs/nls!vs/base/common/actions","vs/nls!vs/base/common/errorMessage","vs/base/common/errorMessage","vs/nls!vs/base/common/keybindingLabels","vs/nls!vs/base/common/platform","vs/base/common/process","vs/base/common/fuzzyScorer","vs/base/common/labels","vs/base/browser/ui/scrollbar/scrollbarVisibilityController","vs/base/browser/ui/tree/compressedObjectTreeModel","vs/base/browser/ui/dropdown/dropdown","vs/base/browser/ui/list/rowCache","vs/base/browser/ui/progressbar/progressbar","vs/base/browser/ui/selectBox/selectBoxNative","vs/base/browser/ui/scrollbar/horizontalScrollbar","vs/base/browser/ui/scrollbar/verticalScrollbar","vs/base/browser/ui/list/listPaging","vs/base/browser/ui/table/tableWidget","vs/base/browser/ui/button/button","vs/base/browser/ui/selectBox/selectBoxCustom","vs/base/browser/ui/selectBox/selectBox","vs/base/browser/ui/findinput/replaceInput","vs/base/browser/ui/menu/menu","vs/base/browser/ui/toolbar/toolbar","vs/base/browser/ui/tree/dataTree","vs/base/browser/ui/tree/asyncDataTree","vs/base/browser/defaultWorkerFactory","vs/base/parts/storage/common/storage","vs/editor/browser/viewParts/contentWidgets/contentWidgets","vs/editor/browser/widget/codeEditorContributions","vs/editor/browser/view/domLineBreaksComputer","vs/editor/browser/view/viewOverlays","vs/editor/common/languageSelector","vs/editor/common/languageFeatureRegistry","vs/editor/common/languages/supports/electricCharacter","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/bracketPairsTree","vs/editor/common/model/bracketPairsTextModelPart/bracketPairsImpl","vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder","vs/editor/common/services/semanticTokensDto","vs/editor/common/tokenizationRegistry","vs/editor/common/model/textModelTokens","vs/editor/common/model/tokenizationTextModelPart","vs/editor/common/services/editorSimpleWorker","vs/editor/common/viewLayout/viewLayout","vs/editor/contrib/colorPicker/browser/colorPickerModel","vs/editor/contrib/folding/browser/foldingModel","vs/editor/contrib/folding/browser/hiddenRangeModel","vs/nls!vs/editor/browser/controller/textAreaHandler","vs/nls!vs/editor/browser/coreCommands","vs/nls!vs/editor/browser/editorExtensions","vs/nls!vs/editor/browser/widget/codeEditorWidget","vs/nls!vs/editor/browser/widget/diffEditorWidget","vs/nls!vs/editor/browser/widget/diffReview","vs/nls!vs/editor/browser/widget/inlineDiffMargin","vs/editor/browser/widget/inlineDiffMargin","vs/nls!vs/editor/common/config/editorConfigurationSchema","vs/nls!vs/editor/common/config/editorOptions","vs/editor/browser/viewParts/viewCursors/viewCursor","vs/nls!vs/editor/common/core/editorColorRegistry","vs/nls!vs/editor/common/editorContextKeys","vs/nls!vs/editor/common/languages/modesRegistry","vs/nls!vs/editor/common/model/editStack","vs/nls!vs/editor/common/standaloneStrings","vs/nls!vs/editor/common/viewLayout/viewLineRenderer","vs/nls!vs/editor/contrib/anchorSelect/browser/anchorSelect","vs/nls!vs/editor/contrib/bracketMatching/browser/bracketMatching","vs/nls!vs/editor/contrib/caretOperations/browser/caretOperations","vs/nls!vs/editor/contrib/caretOperations/browser/transpose","vs/nls!vs/editor/contrib/clipboard/browser/clipboard","vs/nls!vs/editor/contrib/codeAction/browser/codeAction","vs/nls!vs/editor/contrib/codeAction/browser/codeActionCommands","vs/nls!vs/editor/contrib/codeAction/browser/codeActionContributions","vs/nls!vs/editor/contrib/codeAction/browser/codeActionMenu","vs/nls!vs/editor/contrib/codeAction/browser/codeActionUi","vs/nls!vs/editor/contrib/codeAction/browser/lightBulbWidget","vs/nls!vs/editor/contrib/codelens/browser/codelensController","vs/nls!vs/editor/contrib/colorPicker/browser/colorPickerWidget","vs/nls!vs/editor/contrib/comment/browser/comment","vs/nls!vs/editor/contrib/contextmenu/browser/contextmenu","vs/nls!vs/editor/contrib/copyPaste/browser/copyPasteContribution","vs/nls!vs/editor/contrib/copyPaste/browser/copyPasteController","vs/nls!vs/editor/contrib/cursorUndo/browser/cursorUndo","vs/nls!vs/editor/contrib/dropIntoEditor/browser/dropIntoEditorContribution","vs/nls!vs/editor/contrib/editorState/browser/keybindingCancellation","vs/nls!vs/editor/contrib/find/browser/findController","vs/nls!vs/editor/contrib/find/browser/findWidget","vs/nls!vs/editor/contrib/folding/browser/folding","vs/nls!vs/editor/contrib/folding/browser/foldingDecorations","vs/nls!vs/editor/contrib/fontZoom/browser/fontZoom","vs/nls!vs/editor/contrib/format/browser/format","vs/nls!vs/editor/contrib/format/browser/formatActions","vs/nls!vs/editor/contrib/gotoError/browser/gotoError","vs/nls!vs/editor/contrib/gotoError/browser/gotoErrorWidget","vs/nls!vs/editor/contrib/gotoSymbol/browser/goToCommands","vs/nls!vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition","vs/nls!vs/editor/contrib/gotoSymbol/browser/peek/referencesController","vs/nls!vs/editor/contrib/gotoSymbol/browser/peek/referencesTree","vs/nls!vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget","vs/nls!vs/editor/contrib/gotoSymbol/browser/referencesModel","vs/nls!vs/editor/contrib/gotoSymbol/browser/symbolNavigation","vs/nls!vs/editor/contrib/hover/browser/hover","vs/nls!vs/editor/contrib/hover/browser/markdownHoverParticipant","vs/nls!vs/editor/contrib/hover/browser/markerHoverParticipant","vs/nls!vs/editor/contrib/inPlaceReplace/browser/inPlaceReplace","vs/nls!vs/editor/contrib/indentation/browser/indentation","vs/nls!vs/editor/contrib/inlayHints/browser/inlayHintsHover","vs/nls!vs/editor/contrib/inlineCompletions/browser/ghostTextController","vs/nls!vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant","vs/nls!vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget","vs/nls!vs/editor/contrib/lineSelection/browser/lineSelection","vs/nls!vs/editor/contrib/linesOperations/browser/linesOperations","vs/nls!vs/editor/contrib/linkedEditing/browser/linkedEditing","vs/nls!vs/editor/contrib/links/browser/links","vs/nls!vs/editor/contrib/message/browser/messageController","vs/nls!vs/editor/contrib/multicursor/browser/multicursor","vs/nls!vs/editor/contrib/parameterHints/browser/parameterHints","vs/nls!vs/editor/contrib/parameterHints/browser/parameterHintsWidget","vs/nls!vs/editor/contrib/peekView/browser/peekView","vs/nls!vs/editor/contrib/quickAccess/browser/gotoLineQuickAccess","vs/nls!vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess","vs/nls!vs/editor/contrib/readOnlyMessage/browser/contribution","vs/nls!vs/editor/contrib/rename/browser/rename","vs/nls!vs/editor/contrib/rename/browser/renameInputField","vs/nls!vs/editor/contrib/smartSelect/browser/smartSelect","vs/nls!vs/editor/contrib/snippet/browser/snippetController2","vs/nls!vs/editor/contrib/snippet/browser/snippetVariables","vs/nls!vs/editor/contrib/stickyScroll/browser/stickyScrollActions","vs/nls!vs/editor/contrib/suggest/browser/suggest","vs/nls!vs/editor/contrib/suggest/browser/suggestController","vs/nls!vs/editor/contrib/suggest/browser/suggestWidget","vs/nls!vs/editor/contrib/suggest/browser/suggestWidgetDetails","vs/nls!vs/editor/contrib/suggest/browser/suggestWidgetRenderer","vs/nls!vs/editor/contrib/suggest/browser/suggestWidgetStatus","vs/nls!vs/editor/contrib/symbolIcons/browser/symbolIcons","vs/nls!vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode","vs/nls!vs/editor/contrib/tokenization/browser/tokenization","vs/nls!vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter","vs/nls!vs/editor/contrib/unusualLineTerminators/browser/unusualLineTerminators","vs/nls!vs/editor/contrib/wordHighlighter/browser/highlightDecorations","vs/nls!vs/editor/contrib/wordHighlighter/browser/wordHighlighter","vs/nls!vs/editor/contrib/wordOperations/browser/wordOperations","vs/nls!vs/platform/action/common/actionCommonCategories","vs/nls!vs/platform/actionWidget/browser/actionList","vs/nls!vs/platform/actionWidget/browser/actionWidget","vs/nls!vs/platform/actions/browser/menuEntryActionViewItem","vs/nls!vs/platform/actions/browser/toolbar","vs/nls!vs/platform/actions/common/menuService","vs/nls!vs/platform/audioCues/browser/audioCueService","vs/nls!vs/platform/configuration/common/configurationRegistry","vs/nls!vs/platform/contextkey/browser/contextKeyService","vs/nls!vs/platform/contextkey/common/contextkey","vs/nls!vs/platform/contextkey/common/contextkeys","vs/nls!vs/platform/contextkey/common/scanner","vs/nls!vs/platform/history/browser/contextScopedHistoryWidget","vs/nls!vs/platform/keybinding/common/abstractKeybindingService","vs/nls!vs/platform/list/browser/listService","vs/nls!vs/platform/markers/common/markers","vs/nls!vs/platform/quickinput/browser/commandsQuickAccess","vs/nls!vs/platform/quickinput/browser/helpQuickAccess","vs/nls!vs/platform/quickinput/browser/quickInput","vs/nls!vs/platform/quickinput/browser/quickInputList","vs/nls!vs/platform/quickinput/browser/quickInputUtils","vs/nls!vs/platform/theme/common/colorRegistry","vs/nls!vs/platform/theme/common/iconRegistry","vs/nls!vs/platform/undoRedo/common/undoRedoService","vs/nls!vs/platform/workspace/common/workspace","vs/platform/action/common/action","vs/platform/action/common/actionCommonCategories","vs/platform/contextkey/common/scanner","vs/platform/editor/common/editor","vs/platform/extensions/common/extensions","vs/platform/history/browser/historyWidgetKeybindingHint","vs/platform/instantiation/common/graph","vs/editor/browser/widget/workerBasedDocumentDiffProvider","vs/editor/common/services/languageFeaturesService","vs/editor/contrib/inlineCompletions/browser/ghostTextWidget","vs/editor/contrib/links/browser/getLinks","vs/editor/standalone/browser/colorizer","vs/editor/contrib/parameterHints/browser/parameterHintsModel","vs/editor/contrib/suggest/browser/suggestAlternatives","vs/editor/contrib/suggest/browser/wordContextKey","vs/editor/browser/config/editorConfiguration","vs/platform/contextkey/browser/contextKeyService","vs/platform/instantiation/common/instantiationService","vs/platform/keybinding/common/abstractKeybindingService","vs/platform/keybinding/common/baseResolvedKeybinding","vs/platform/keybinding/common/keybindingResolver","vs/platform/keybinding/common/usLayoutResolvedKeybinding","vs/platform/accessibility/browser/accessibilityService","vs/platform/contextview/browser/contextViewService","vs/platform/clipboard/browser/clipboardService","vs/platform/log/common/logService","vs/editor/contrib/gotoError/browser/markerNavigationService","vs/platform/markers/common/markerService","vs/editor/browser/services/openerService","vs/editor/contrib/documentSymbols/browser/documentSymbols","vs/platform/opener/browser/link","vs/platform/quickinput/browser/pickerQuickAccess","vs/platform/quickinput/browser/quickInputBox","vs/platform/quickinput/browser/quickInputList","vs/platform/quickinput/browser/quickInput","vs/platform/dnd/browser/dnd","vs/editor/browser/services/webWorker","vs/editor/common/cursor/cursor","vs/editor/common/services/getIconClasses","vs/editor/common/services/languagesAssociations","vs/editor/common/services/languagesRegistry","vs/editor/common/services/languageService","vs/editor/contrib/linesOperations/browser/moveLinesCommand","vs/editor/contrib/hover/browser/marginHover","vs/platform/configuration/common/configurations","vs/platform/quickinput/browser/helpQuickAccess","vs/editor/standalone/browser/quickAccess/standaloneHelpQuickAccess","vs/platform/quickinput/browser/quickAccess","vs/platform/severityIcon/browser/severityIcon","vs/editor/contrib/codelens/browser/codeLensCache","vs/platform/actions/common/menuService","vs/editor/browser/services/markerDecorations","vs/editor/browser/view/viewController","vs/editor/contrib/anchorSelect/browser/anchorSelect","vs/editor/contrib/caretOperations/browser/caretOperations","vs/editor/contrib/caretOperations/browser/transpose","vs/editor/contrib/clipboard/browser/clipboard","vs/editor/contrib/comment/browser/comment","vs/editor/contrib/cursorUndo/browser/cursorUndo","vs/editor/contrib/editorState/browser/keybindingCancellation","vs/editor/contrib/codeAction/browser/codeActionKeybindingResolver","vs/editor/contrib/copyPaste/browser/copyPasteController","vs/editor/contrib/copyPaste/browser/copyPasteContribution","vs/editor/contrib/fontZoom/browser/fontZoom","vs/editor/contrib/format/browser/formatActions","vs/editor/contrib/gotoSymbol/browser/symbolNavigation","vs/editor/contrib/indentation/browser/indentation","vs/editor/contrib/lineSelection/browser/lineSelection","vs/editor/contrib/linesOperations/browser/linesOperations","vs/editor/contrib/longLinesHelper/browser/longLinesHelper","vs/editor/contrib/readOnlyMessage/browser/contribution","vs/editor/contrib/smartSelect/browser/smartSelect","vs/editor/contrib/suggest/browser/suggestInlineCompletions","vs/editor/contrib/suggest/browser/suggestModel","vs/editor/contrib/tokenization/browser/tokenization","vs/editor/contrib/unusualLineTerminators/browser/unusualLineTerminators","vs/editor/contrib/wordPartOperations/browser/wordPartOperations","vs/editor/standalone/browser/accessibilityHelp/accessibilityHelp","vs/editor/standalone/browser/iPadShowKeyboard/iPadShowKeyboard","vs/editor/standalone/browser/inspectTokens/inspectTokens","vs/platform/actions/browser/toolbar","vs/platform/quickinput/browser/commandsQuickAccess","vs/editor/contrib/quickAccess/browser/commandsQuickAccess","vs/editor/standalone/browser/quickAccess/standaloneCommandsQuickAccess","vs/editor/browser/viewParts/minimap/minimap","vs/editor/contrib/colorPicker/browser/colorPickerWidget","vs/editor/contrib/codeAction/browser/codeActionMenu","vs/editor/contrib/gotoSymbol/browser/peek/referencesTree","vs/platform/actionWidget/browser/actionList","vs/platform/actionWidget/browser/actionWidget","vs/editor/contrib/codeAction/browser/codeActionUi","vs/editor/contrib/codeAction/browser/codeActionCommands","vs/editor/contrib/codeAction/browser/codeActionContributions","vs/platform/contextview/browser/contextMenuHandler","vs/editor/browser/widget/diffReview","vs/editor/contrib/parameterHints/browser/parameterHintsWidget","vs/editor/contrib/parameterHints/browser/parameterHints","vs/editor/contrib/unicodeHighlighter/browser/bannerController","vs/platform/theme/browser/iconsStyleSheet","vs/editor/browser/controller/mouseHandler","vs/editor/browser/controller/pointerHandler","vs/editor/browser/viewParts/lines/viewLines","vs/editor/browser/services/abstractCodeEditorService","vs/editor/browser/viewParts/editorScrollbar/editorScrollbar","vs/editor/browser/viewParts/selections/selections","vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight","vs/editor/browser/viewParts/indentGuides/indentGuides","vs/editor/browser/controller/textAreaHandler","vs/editor/browser/viewParts/overviewRuler/decorationsOverviewRuler","vs/editor/browser/viewParts/viewCursors/viewCursors","vs/editor/browser/viewParts/whitespace/whitespace","vs/editor/browser/view","vs/editor/common/model/bracketPairsTextModelPart/colorizedBracketPairsDecorationProvider","vs/editor/common/services/markerDecorationsService","vs/editor/common/services/semanticTokensStylingService","vs/editor/contrib/quickAccess/browser/gotoLineQuickAccess","vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess","vs/editor/contrib/rename/browser/renameInputField","vs/editor/contrib/rename/browser/rename","vs/editor/contrib/semanticTokens/browser/documentSemanticTokens","vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens","vs/editor/contrib/suggest/browser/suggestWidgetRenderer","vs/editor/standalone/browser/quickAccess/standaloneGotoLineQuickAccess","vs/editor/standalone/browser/quickAccess/standaloneGotoSymbolQuickAccess","vs/editor/standalone/common/themes","vs/editor/standalone/browser/toggleHighContrast/toggleHighContrast","vs/editor/contrib/suggest/browser/suggestWidgetStatus","vs/platform/contextview/browser/contextMenuService","vs/platform/quickinput/browser/quickInputService","vs/editor/standalone/browser/quickInput/standaloneQuickInputService","vs/editor/common/services/modelService","vs/editor/common/viewModel/viewModelLines","vs/editor/common/viewModel/viewModelImpl","vs/editor/contrib/bracketMatching/browser/bracketMatching","vs/editor/contrib/codelens/browser/codelensWidget","vs/editor/contrib/codelens/browser/codelensController","vs/editor/contrib/colorPicker/browser/colorHoverParticipant","vs/editor/contrib/dnd/browser/dnd","vs/editor/contrib/find/browser/findDecorations","vs/editor/contrib/find/browser/findOptionsWidget","vs/editor/contrib/find/browser/findState","vs/editor/contrib/find/browser/findWidget","vs/editor/contrib/folding/browser/foldingDecorations","vs/editor/contrib/hover/browser/contentHover","vs/editor/contrib/inPlaceReplace/browser/inPlaceReplace","vs/editor/contrib/linkedEditing/browser/linkedEditing","vs/editor/contrib/links/browser/links","vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider","vs/editor/contrib/stickyScroll/browser/stickyScrollProvider","vs/editor/contrib/stickyScroll/browser/stickyScrollWidget","vs/editor/contrib/suggest/browser/suggestWidget","vs/editor/contrib/multicursor/browser/multicursor","vs/editor/contrib/wordHighlighter/browser/wordHighlighter","vs/editor/contrib/zoneWidget/browser/zoneWidget","vs/editor/contrib/gotoError/browser/gotoErrorWidget","vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget","vs/editor/contrib/hover/browser/markerHoverParticipant","vs/editor/contrib/colorPicker/browser/colorContributions","vs/editor/contrib/inlayHints/browser/inlayHintsHover","vs/editor/contrib/inlayHints/browser/inlayHintsContribution","vs/editor/contrib/stickyScroll/browser/stickyScrollActions","vs/editor/contrib/stickyScroll/browser/stickyScrollContribution","vs/editor/standalone/browser/referenceSearch/standaloneReferenceSearch","vs/platform/undoRedo/common/undoRedoService","vs/editor/contrib/contextmenu/browser/contextmenu","vs/editor/contrib/dropIntoEditor/browser/dropIntoEditorContribution","vs/editor/contrib/snippet/browser/snippetVariables","vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider","vs/editor/contrib/inlineCompletions/browser/suggestWidgetPreviewModel","vs/editor/contrib/inlineCompletions/browser/ghostTextModel","vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant","vs/editor/contrib/inlineCompletions/browser/ghostText.contribution","vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter","vs/editor/editor.all","vs/editor/standalone/browser/standaloneCodeEditor","vs/editor/standalone/browser/standaloneEditor","vs/editor/standalone/browser/standaloneLanguages","vs/editor/editor.api","vs/css","vs/editor/edcore.main"],J=function(j){for(var e=[],S=0,k=j.length;S{g({})},i=>{typeof g.error=="function"&&g.error("Could not find "+t+".")})}e.load=S;function k(b,C,g,m){if(y(b,C)){g();return}D(b,C,g,m)}function y(b,C){const g=document.getElementsByTagName("link");for(let m=0,n=g.length;m{C.removeEventListener("load",t),C.removeEventListener("error",i)},t=s=>{n(),g()},i=s=>{n(),m(s)};C.addEventListener("load",t),C.addEventListener("error",i)}});var Ee=this&&this.__awaiter||function(j,e,S,k){function y(D){return D instanceof S?D:new S(function(w){w(D)})}return new(S||(S=Promise))(function(D,w){function b(m){try{g(k.next(m))}catch(n){w(n)}}function C(m){try{g(k.throw(m))}catch(n){w(n)}}function g(m){m.done?D(m.value):y(m.value).then(b,C)}g((k=k.apply(j,e||[])).next())})};define(X[3],J([1,0]),function(j,e){"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.load=e.create=e.setPseudoTranslation=e.getConfiguredDefaultLocale=e.localize=void 0;let S=typeof document<"u"&&document.location&&document.location.hash.indexOf("pseudo=true")>=0;const k="i-default";function y(s,a){let c;return a.length===0?c=s:c=s.replace(/\{(\d+)\}/g,(h,o)=>{const l=o[0],r=a[l];let d=h;return typeof r=="string"?d=r:(typeof r=="number"||typeof r=="boolean"||r===void 0||r===null)&&(d=String(r)),d}),S&&(c="\uFF3B"+c.replace(/[aouei]/g,"$&$&")+"\uFF3D"),c}function D(s,a){let c=s[a];return c||(c=s["*"],c)?c:null}function w(s){return s.charAt(s.length-1)==="/"?s:s+"/"}function b(s,a,c){return Ee(this,void 0,void 0,function*(){const h=w(s)+w(a)+"vscode/"+w(c),o=yield fetch(h);if(o.ok)return yield o.json();throw new Error(`${o.status} - ${o.statusText}`)})}function C(s){return function(a,c){const h=Array.prototype.slice.call(arguments,2);return y(s[a],h)}}function g(s,a,...c){return y(a,c)}e.localize=g;function m(s){}e.getConfiguredDefaultLocale=m;function n(s){S=s}e.setPseudoTranslation=n;function t(s,a){var c;return{localize:C(a[s]),getConfiguredDefaultLocale:(c=a.getConfiguredDefaultLocale)!==null&&c!==void 0?c:h=>{}}}e.create=t;function i(s,a,c,h){var o;const l=(o=h["vs/nls"])!==null&&o!==void 0?o:{};if(!s||s.length===0)return c({localize:g,getConfiguredDefaultLocale:()=>{var f;return(f=l.availableLanguages)===null||f===void 0?void 0:f["*"]}});const r=l.availableLanguages?D(l.availableLanguages,s):null,d=r===null||r===k;let u=".nls";d||(u=u+"."+r);const _=f=>{Array.isArray(f)?f.localize=C(f):f.localize=C(f[s]),f.getConfiguredDefaultLocale=()=>{var v;return(v=l.availableLanguages)===null||v===void 0?void 0:v["*"]},c(f)};typeof l.loadBundle=="function"?l.loadBundle(s,r,(f,v)=>{f?a([s+".nls"],_):_(v)}):l.translationServiceUrl&&!d?Ee(this,void 0,void 0,function*(){var f;try{const v=yield b(l.translationServiceUrl,r,s);return _(v)}catch(v){if(!r.includes("-"))return console.error(v),a([s+".nls"],_);try{const p=r.split("-")[0],L=yield b(l.translationServiceUrl,p,s);return(f=l.availableLanguages)!==null&&f!==void 0||(l.availableLanguages={}),l.availableLanguages["*"]=p,_(L)}catch(p){return console.error(p),a([s+".nls"],_)}}}):a([s+u],_,f=>{if(u===".nls"){console.error("Failed trying to load default language strings",f);return}console.error(`Failed to load message bundle for language ${r}. Falling back to the default language:`,f),a([s+".nls"],_)})}e.load=i});/*! @license DOMPurify 2.3.1 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/2.3.1/LICENSE */function un(j){if(Array.isArray(j)){for(var e=0,S=Array(j.length);e1?S-1:0),y=1;y/gm),En=bt(/^data-[\-\w.\u00B7-\uFFFF]/),Dn=bt(/^aria-[\-\w]+$/),In=bt(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),kn=bt(/^(?:\w+script|data):/i),Tn=bt(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),Ot=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(j){return typeof j}:function(j){return j&&typeof Symbol=="function"&&j.constructor===Symbol&&j!==Symbol.prototype?"symbol":typeof j};function pt(j){if(Array.isArray(j)){for(var e=0,S=Array(j.length);e"u"?null:window},Mn=function(e,S){if((typeof e>"u"?"undefined":Ot(e))!=="object"||typeof e.createPolicy!="function")return null;var k=null,y="data-tt-policy-suffix";S.currentScript&&S.currentScript.hasAttribute(y)&&(k=S.currentScript.getAttribute(y));var D="dompurify"+(k?"#"+k:"");try{return e.createPolicy(D,{createHTML:function(b){return b}})}catch{return console.warn("TrustedTypes policy "+D+" could not be created."),null}};function Ai(){var j=arguments.length>0&&arguments[0]!==void 0?arguments[0]:Nn(),e=function(Me){return Ai(Me)};if(e.version="2.3.1",e.removed=[],!j||!j.document||j.document.nodeType!==9)return e.isSupported=!1,e;var S=j.document,k=j.document,y=j.DocumentFragment,D=j.HTMLTemplateElement,w=j.Node,b=j.Element,C=j.NodeFilter,g=j.NamedNodeMap,m=g===void 0?j.NamedNodeMap||j.MozNamedAttrMap:g,n=j.Text,t=j.Comment,i=j.DOMParser,s=j.trustedTypes,a=b.prototype,c=Ut(a,"cloneNode"),h=Ut(a,"nextSibling"),o=Ut(a,"childNodes"),l=Ut(a,"parentNode");if(typeof D=="function"){var r=k.createElement("template");r.content&&r.content.ownerDocument&&(k=r.content.ownerDocument)}var d=Mn(s,S),u=d&&he?d.createHTML(""):"",_=k,f=_.implementation,v=_.createNodeIterator,p=_.createDocumentFragment,L=_.getElementsByTagName,E=S.importNode,I={};try{I=It(k).documentMode?k.documentMode:{}}catch{}var T={};e.isSupported=typeof l=="function"&&f&&typeof f.createHTMLDocument<"u"&&I!==9;var F=Ln,R=yn,M=En,N=Dn,A=kn,P=Tn,x=In,W=null,U=et({},[].concat(pt(ki),pt(ti),pt(ii),pt(ni),pt(Ti))),O=null,$=et({},[].concat(pt(Ni),pt(si),pt(Mi),pt(Kt))),Q=null,se=null,le=!0,te=!0,re=!1,H=!1,B=!1,q=!1,G=!1,Y=!1,ne=!1,de=!0,he=!1,oe=!0,ce=!0,z=!1,V={},K=null,ie=et({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),ee=null,Z=et({},["audio","video","img","source","image","track"]),ue=null,me=et({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Ce="http://www.w3.org/1998/Math/MathML",_e="http://www.w3.org/2000/svg",we="http://www.w3.org/1999/xhtml",ye=we,ke=!1,De=null,ge=k.createElement("form"),pe=function(Me){De&&De===Me||((!Me||(typeof Me>"u"?"undefined":Ot(Me))!=="object")&&(Me={}),Me=It(Me),W="ALLOWED_TAGS"in Me?et({},Me.ALLOWED_TAGS):U,O="ALLOWED_ATTR"in Me?et({},Me.ALLOWED_ATTR):$,ue="ADD_URI_SAFE_ATTR"in Me?et(It(me),Me.ADD_URI_SAFE_ATTR):me,ee="ADD_DATA_URI_TAGS"in Me?et(It(Z),Me.ADD_DATA_URI_TAGS):Z,K="FORBID_CONTENTS"in Me?et({},Me.FORBID_CONTENTS):ie,Q="FORBID_TAGS"in Me?et({},Me.FORBID_TAGS):{},se="FORBID_ATTR"in Me?et({},Me.FORBID_ATTR):{},V="USE_PROFILES"in Me?Me.USE_PROFILES:!1,le=Me.ALLOW_ARIA_ATTR!==!1,te=Me.ALLOW_DATA_ATTR!==!1,re=Me.ALLOW_UNKNOWN_PROTOCOLS||!1,H=Me.SAFE_FOR_TEMPLATES||!1,B=Me.WHOLE_DOCUMENT||!1,Y=Me.RETURN_DOM||!1,ne=Me.RETURN_DOM_FRAGMENT||!1,de=Me.RETURN_DOM_IMPORT!==!1,he=Me.RETURN_TRUSTED_TYPE||!1,G=Me.FORCE_BODY||!1,oe=Me.SANITIZE_DOM!==!1,ce=Me.KEEP_CONTENT!==!1,z=Me.IN_PLACE||!1,x=Me.ALLOWED_URI_REGEXP||x,ye=Me.NAMESPACE||we,H&&(te=!1),ne&&(Y=!0),V&&(W=et({},[].concat(pt(Ti))),O=[],V.html===!0&&(et(W,ki),et(O,Ni)),V.svg===!0&&(et(W,ti),et(O,si),et(O,Kt)),V.svgFilters===!0&&(et(W,ii),et(O,si),et(O,Kt)),V.mathMl===!0&&(et(W,ni),et(O,Mi),et(O,Kt))),Me.ADD_TAGS&&(W===U&&(W=It(W)),et(W,Me.ADD_TAGS)),Me.ADD_ATTR&&(O===$&&(O=It(O)),et(O,Me.ADD_ATTR)),Me.ADD_URI_SAFE_ATTR&&et(ue,Me.ADD_URI_SAFE_ATTR),Me.FORBID_CONTENTS&&(K===ie&&(K=It(K)),et(K,Me.FORBID_CONTENTS)),ce&&(W["#text"]=!0),B&&et(W,["html","head","body"]),W.table&&(et(W,["tbody"]),delete Q.tbody),ft&&ft(Me),De=Me)},ae=et({},["mi","mo","mn","ms","mtext"]),ve=et({},["foreignobject","desc","title","annotation-xml"]),Le=et({},ti);et(Le,ii),et(Le,Sn);var Ie=et({},ni);et(Ie,wn);var Ae=function(Me){var Be=l(Me);(!Be||!Be.tagName)&&(Be={namespaceURI:we,tagName:"template"});var We=Dt(Me.tagName),$e=Dt(Be.tagName);if(Me.namespaceURI===_e)return Be.namespaceURI===we?We==="svg":Be.namespaceURI===Ce?We==="svg"&&($e==="annotation-xml"||ae[$e]):Boolean(Le[We]);if(Me.namespaceURI===Ce)return Be.namespaceURI===we?We==="math":Be.namespaceURI===_e?We==="math"&&ve[$e]:Boolean(Ie[We]);if(Me.namespaceURI===we){if(Be.namespaceURI===_e&&!ve[$e]||Be.namespaceURI===Ce&&!ae[$e])return!1;var Ue=et({},["title","style","font","a","script"]);return!Ie[We]&&(Ue[We]||!Le[We])}return!1},be=function(Me){Rt(e.removed,{element:Me});try{Me.parentNode.removeChild(Me)}catch{try{Me.outerHTML=u}catch{Me.remove()}}},Se=function(Me,Be){try{Rt(e.removed,{attribute:Be.getAttributeNode(Me),from:Be})}catch{Rt(e.removed,{attribute:null,from:Be})}if(Be.removeAttribute(Me),Me==="is"&&!O[Me])if(Y||ne)try{be(Be)}catch{}else try{Be.setAttribute(Me,"")}catch{}},Te=function(Me){var Be=void 0,We=void 0;if(G)Me=""+Me;else{var $e=Di(Me,/^[\r\n\t ]+/);We=$e&&$e[0]}var Ue=d?d.createHTML(Me):Me;if(ye===we)try{Be=new i().parseFromString(Ue,"text/html")}catch{}if(!Be||!Be.documentElement){Be=f.createDocument(ye,"template",null);try{Be.documentElement.innerHTML=ke?"":Ue}catch{}}var Xe=Be.body||Be.documentElement;return Me&&We&&Xe.insertBefore(k.createTextNode(We),Xe.childNodes[0]||null),ye===we?L.call(Be,B?"html":"body")[0]:B?Be.documentElement:Xe},Pe=function(Me){return v.call(Me.ownerDocument||Me,Me,C.SHOW_ELEMENT|C.SHOW_COMMENT|C.SHOW_TEXT,null,!1)},Fe=function(Me){return Me instanceof n||Me instanceof t?!1:typeof Me.nodeName!="string"||typeof Me.textContent!="string"||typeof Me.removeChild!="function"||!(Me.attributes instanceof m)||typeof Me.removeAttribute!="function"||typeof Me.setAttribute!="function"||typeof Me.namespaceURI!="string"||typeof Me.insertBefore!="function"},Oe=function(Me){return(typeof w>"u"?"undefined":Ot(w))==="object"?Me instanceof w:Me&&(typeof Me>"u"?"undefined":Ot(Me))==="object"&&typeof Me.nodeType=="number"&&typeof Me.nodeName=="string"},xe=function(Me,Be,We){!T[Me]||vn(T[Me],function($e){$e.call(e,Be,We,De)})},Ke=function(Me){var Be=void 0;if(xe("beforeSanitizeElements",Me,null),Fe(Me)||Di(Me.nodeName,/[\u0080-\uFFFF]/))return be(Me),!0;var We=Dt(Me.nodeName);if(xe("uponSanitizeElement",Me,{tagName:We,allowedTags:W}),!Oe(Me.firstElementChild)&&(!Oe(Me.content)||!Oe(Me.content.firstElementChild))&&_t(/<[/\w]/g,Me.innerHTML)&&_t(/<[/\w]/g,Me.textContent)||We==="select"&&_t(/