mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
8422045b86
How to test: 1. Connect a project to GH repo 2. Deploy the `config.toml` once 3. Change some setting in Auth 4. You should see a change in `/dashboard/project/_/settings/code-configuration` <img width="1271" height="1186" alt="Screenshot 2026-09-16 at 16 26 39" src="https://github.com/user-attachments/assets/dfc135a4-e495-489e-88fd-b760383793b4" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Configuration drift comparisons now use a consistent project configuration schema. - Drift details display complete current-environment and `config.toml` values, grouped by section. - Matching and unmanaged settings are organized into dedicated sections. - Configuration fields link directly to relevant Studio settings. - Added a warning that GitHub deployments overwrite local changes. - **Bug Fixes** - Configuration updates now refresh project configuration data automatically. - Improved labels and formatting for boolean and redirect URL values. - Drift errors identify invalid configuration paths and provide corrective guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
import {
|
|
CliConfigSchema,
|
|
diffProjectConfig,
|
|
fromApiProjectConfig,
|
|
type CliConfig,
|
|
type ConfigChange,
|
|
type ProjectConfig,
|
|
} from '@supabase/config'
|
|
import { Result, Schema, SchemaIssue } from 'effect'
|
|
import { isPlainObject, lowerFirst } from 'lodash'
|
|
|
|
import {
|
|
CONFIG_SECTIONS,
|
|
getFieldDefinition,
|
|
getSectionFieldEntries,
|
|
toProjectHomepageHref,
|
|
type ConfigSection,
|
|
} from './ConfigurationDriftPage.constants'
|
|
|
|
export interface GitHubConfigDriftField {
|
|
section: ConfigSection
|
|
configPath: string
|
|
settingHref: (projectRef: string) => string
|
|
dashboardValue: unknown
|
|
githubValue: unknown
|
|
}
|
|
|
|
export interface UnmanagedConfigField {
|
|
section: ConfigSection
|
|
configPath: string
|
|
dashboardValue: unknown
|
|
}
|
|
|
|
export interface MatchedConfigField {
|
|
section: ConfigSection
|
|
configPath: string
|
|
value: unknown
|
|
}
|
|
|
|
export interface GitHubConfigDriftSummary {
|
|
driftedFields: GitHubConfigDriftField[]
|
|
matchedFields: MatchedConfigField[]
|
|
unmanagedFields: UnmanagedConfigField[]
|
|
}
|
|
|
|
export interface GitHubConfigDecodeIssue {
|
|
path: string // e.g. 'api.max_rows'
|
|
message: string
|
|
}
|
|
|
|
export type GitHubConfigDecodeResult =
|
|
| { status: 'success'; config: CliConfig; document: Record<string, unknown> }
|
|
| { status: 'invalid'; issues: GitHubConfigDecodeIssue[] }
|
|
|
|
const EMPTY_SUMMARY: GitHubConfigDriftSummary = {
|
|
driftedFields: [],
|
|
matchedFields: [],
|
|
unmanagedFields: [],
|
|
}
|
|
|
|
/**
|
|
* Converts a v2 project-config API response's `attributes` into the hosted-section shape both
|
|
* sides of a drift comparison are normalized to. Returns `undefined` when `attributes` isn't
|
|
* loaded yet; throws if the API returned something @supabase/config can't map, so callers can
|
|
* surface it as an error rather than silently reporting no drift.
|
|
*/
|
|
export function fromDashboardProjectConfig(attributes: unknown): ProjectConfig | undefined {
|
|
if (attributes === undefined) return undefined
|
|
return fromApiProjectConfig(attributes)
|
|
}
|
|
|
|
/**
|
|
* Decodes a parsed config.toml document into the `{ config, document }` pair `diffProjectConfig`
|
|
* takes as its local operand. Keeping the raw `document` alongside the decoded `config` is what
|
|
* unlocks raw-presence masking (distinguishing "the file wrote this value" from "the file inherited
|
|
* a schema default") — see `DiffProjectConfigOptions.local`'s own docstring. Returns `undefined`
|
|
* when `document` isn't loaded yet; returns `{ status: 'invalid', issues }` when it fails to decode
|
|
* against the schema, so callers can surface the offending path(s) rather than silently reporting
|
|
* no drift.
|
|
*/
|
|
export function decodeGithubConfigDocument(
|
|
document: unknown
|
|
): GitHubConfigDecodeResult | undefined {
|
|
if (!isRecord(document)) return undefined
|
|
|
|
const result = Schema.decodeUnknownResult(CliConfigSchema, { errors: 'all' })(document)
|
|
if (Result.isFailure(result)) {
|
|
const formatter = SchemaIssue.makeFormatterStandardSchemaV1()
|
|
const issues = formatter(result.failure.issue).issues.map((issue) => ({
|
|
path: (issue.path ?? []).map(String).join('.'),
|
|
message: issue.message,
|
|
}))
|
|
return { status: 'invalid', issues }
|
|
}
|
|
|
|
return { status: 'success', config: result.success, document }
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return isPlainObject(value)
|
|
}
|
|
|
|
/**
|
|
* Whether `configPath` (dotted, e.g. `api.max_rows`) is declared anywhere in the raw config.toml
|
|
* document — walking the parsed document itself rather than the decoded config, since decoding
|
|
* fills in schema defaults for every field the file never mentioned. This is the only reliable way
|
|
* to tell "config.toml set this" apart from "config.toml is silent and this happens to equal the
|
|
* default" — a distinction `diffProjectConfig`'s change classification collapses when the two
|
|
* sides' values coincide.
|
|
*/
|
|
function isPathDeclaredInDocument(
|
|
document: Record<string, unknown> | undefined,
|
|
configPath: string
|
|
): boolean {
|
|
let current: unknown = document
|
|
for (const segment of configPath.split('.')) {
|
|
if (!isRecord(current) || !(segment in current)) return false
|
|
current = current[segment]
|
|
}
|
|
return true
|
|
}
|
|
|
|
export type ConfigDriftResult =
|
|
| { status: 'success'; summary: GitHubConfigDriftSummary }
|
|
| { status: 'invalid-config'; issues: GitHubConfigDecodeIssue[] }
|
|
|
|
/**
|
|
* Returns `{ status: 'invalid-config', issues }` if `githubConfig` fails to decode against the
|
|
* schema (see `decodeGithubConfigDocument`), so callers can surface the offending path(s) instead
|
|
* of silently reporting no drift.
|
|
*/
|
|
export function getConfigDriftSummary({
|
|
dashboardConfig,
|
|
githubConfig,
|
|
}: {
|
|
dashboardConfig?: ProjectConfig
|
|
githubConfig?: Record<string, unknown>
|
|
}): ConfigDriftResult {
|
|
if (!dashboardConfig || !githubConfig) {
|
|
return { status: 'success', summary: EMPTY_SUMMARY }
|
|
}
|
|
|
|
const decodedGithubConfig = decodeGithubConfigDocument(githubConfig)
|
|
if (!decodedGithubConfig) {
|
|
return { status: 'success', summary: EMPTY_SUMMARY }
|
|
}
|
|
if (decodedGithubConfig.status === 'invalid') {
|
|
return { status: 'invalid-config', issues: decodedGithubConfig.issues }
|
|
}
|
|
|
|
const changeSet = diffProjectConfig({
|
|
local: { config: decodedGithubConfig.config, document: decodedGithubConfig.document },
|
|
remote: dashboardConfig,
|
|
})
|
|
|
|
const changesByPath = new Map<string, ConfigChange>(
|
|
changeSet.changes.map((change) => [change.path.join('.'), change])
|
|
)
|
|
const maskedPaths = new Set(changeSet.masked.map((path) => path.join('.')))
|
|
const unmanagedByPushPaths = new Set(changeSet.unmanaged.map((path) => path.join('.')))
|
|
|
|
const driftedFields: GitHubConfigDriftField[] = []
|
|
const matchedFields: MatchedConfigField[] = []
|
|
const unmanagedFields: UnmanagedConfigField[] = []
|
|
|
|
for (const section of CONFIG_SECTIONS) {
|
|
const sectionConfig = dashboardConfig[section]
|
|
if (!sectionConfig) continue
|
|
|
|
for (const { configPath, rawValue } of getSectionFieldEntries(section, sectionConfig)) {
|
|
if (maskedPaths.has(configPath)) continue
|
|
|
|
const change = changesByPath.get(configPath)
|
|
if (change) {
|
|
const definition = getFieldDefinition(configPath)
|
|
|
|
driftedFields.push({
|
|
section,
|
|
configPath,
|
|
settingHref: definition?.settingHref ?? toProjectHomepageHref,
|
|
dashboardValue: change.remote,
|
|
githubValue: change.local,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const isTrackedInConfigToml = isPathDeclaredInDocument(
|
|
decodedGithubConfig.document,
|
|
configPath
|
|
)
|
|
if (unmanagedByPushPaths.has(configPath) || !isTrackedInConfigToml) {
|
|
unmanagedFields.push({ section, configPath, dashboardValue: rawValue })
|
|
continue
|
|
}
|
|
|
|
matchedFields.push({ section, configPath, value: rawValue })
|
|
}
|
|
}
|
|
|
|
return { status: 'success', summary: { driftedFields, matchedFields, unmanagedFields } }
|
|
}
|
|
|
|
/**
|
|
* Turns `GitHubConfigDecodeIssue[]` into a single-sentence, user-facing message for `AlertError`,
|
|
* which renders `error.message` in a `<p>` — Effect's own multi-line default reads badly there.
|
|
*/
|
|
export function formatGitHubConfigDecodeMessage(issues: GitHubConfigDecodeIssue[]): string {
|
|
if (issues.length === 1) {
|
|
const [issue] = issues
|
|
return `config.toml has an invalid value at ${issue.path}: ${lowerFirst(issue.message)}.`
|
|
}
|
|
|
|
const details = issues.map((issue) => `${issue.path} (${lowerFirst(issue.message)})`).join(', ')
|
|
return `config.toml has invalid values: ${details}.`
|
|
}
|