From 39ff587b8d6a21d7a079ce7959ca5c524e66b571 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 23 Oct 2026 10:20:12 -0600 Subject: [PATCH 1/4] feat(config): add db.migrations.checks severities and exclusions (CLI-2782, CLI-2783) Adds the [db.migrations.checks] table with per-rule severities and per-rule exclusion lists. Rule names, severities and exclusion entries are validated with path-scoped messages, and the section is published in the CLI config JSON Schema. A CLI policy resolver defaults unset rules to warn. --- .../migration-checks-policy.ts | 32 ++ .../migration-checks-policy.unit.test.ts | 60 ++++ apps/docs/public/cli/config.schema.json | 280 ++++++++++++++++++ packages/config/AGENTS.md | 2 + packages/config/src/db-migration-checks.ts | 226 ++++++++++++++ .../src/db-migration-checks.unit.test.ts | 233 +++++++++++++++ packages/config/src/db.ts | 2 + .../config/src/entrypoint-purity.unit.test.ts | 4 + packages/config/src/internal.ts | 7 + packages/config/src/json-schema-document.ts | 23 +- .../src/json-schema-document.unit.test.ts | 36 +++ 11 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/command-internal/migration-checks-policy.ts create mode 100644 apps/cli/src/command-internal/migration-checks-policy.unit.test.ts create mode 100644 packages/config/src/db-migration-checks.ts create mode 100644 packages/config/src/db-migration-checks.unit.test.ts diff --git a/apps/cli/src/command-internal/migration-checks-policy.ts b/apps/cli/src/command-internal/migration-checks-policy.ts new file mode 100644 index 0000000000..bb8380b174 --- /dev/null +++ b/apps/cli/src/command-internal/migration-checks-policy.ts @@ -0,0 +1,32 @@ +import type { CliConfig } from "@supabase/config"; +import { + DEFAULT_MIGRATION_CHECK_SEVERITY, + MIGRATION_CHECK_RULES, + mapMigrationCheckRules, + type MigrationCheckRule, + type MigrationCheckSeverity, +} from "@supabase/config/internal"; + +type MigrationChecksConfig = CliConfig["db"]["migrations"]["checks"]; + +/** One object a rule skips, written "schema.name". */ +interface MigrationCheckExclusion { + readonly rule: MigrationCheckRule; + readonly object: string; +} + +/** The migration check policy in the shape `@supabase/pg-checks` takes. */ +export interface MigrationChecksPolicy { + readonly severities: { readonly [R in MigrationCheckRule]: MigrationCheckSeverity }; + readonly exclude: ReadonlyArray; +} + +/** Resolves `[db.migrations.checks]`: an unset rule is "warn", exclusions become rule/object pairs. */ +export const resolveMigrationChecksPolicy = ( + checks: MigrationChecksConfig, +): MigrationChecksPolicy => ({ + severities: mapMigrationCheckRules((rule) => checks?.[rule] ?? DEFAULT_MIGRATION_CHECK_SEVERITY), + exclude: MIGRATION_CHECK_RULES.flatMap((rule) => + (checks?.exclude?.[rule] ?? []).map((object) => ({ rule, object })), + ), +}); diff --git a/apps/cli/src/command-internal/migration-checks-policy.unit.test.ts b/apps/cli/src/command-internal/migration-checks-policy.unit.test.ts new file mode 100644 index 0000000000..ec720d8327 --- /dev/null +++ b/apps/cli/src/command-internal/migration-checks-policy.unit.test.ts @@ -0,0 +1,60 @@ +import type { CliConfig } from "@supabase/config"; +import { describe, expect, it } from "vitest"; +import { resolveMigrationChecksPolicy } from "./migration-checks-policy.ts"; + +const warnSeverities = { + rls_disabled_in_public: "warn", + rls_enabled_no_policy: "warn", + policy_exists_rls_disabled: "warn", + security_definer_view: "warn", + rls_policy_always_true: "warn", + sensitive_columns_exposed: "warn", + auth_users_exposed: "warn", + function_search_path_mutable: "warn", + rls_references_user_metadata: "warn", +}; + +describe("resolveMigrationChecksPolicy", () => { + it("defaults all nine rules to warn with no exclusions when checks is absent", () => { + expect(resolveMigrationChecksPolicy(undefined)).toEqual({ + severities: warnSeverities, + exclude: [], + }); + }); + + it("keeps explicit severities and fills only unset rules with warn", () => { + const checks: CliConfig["db"]["migrations"]["checks"] = { + rls_disabled_in_public: "error", + rls_enabled_no_policy: "off", + }; + + expect(resolveMigrationChecksPolicy(checks)).toEqual({ + severities: { + ...warnSeverities, + rls_disabled_in_public: "error", + rls_enabled_no_policy: "off", + }, + exclude: [], + }); + }); + + it("flattens exclusions in rule order while keeping entry order", () => { + const checks: CliConfig["db"]["migrations"]["checks"] = { + exclude: { + function_search_path_mutable: ["public.z_function", "public.a_function"], + security_definer_view: [], + rls_disabled_in_public: ["public.z_table", "public.a_table"], + }, + }; + + expect(resolveMigrationChecksPolicy(checks)).toEqual({ + severities: warnSeverities, + exclude: [ + { rule: "rls_disabled_in_public", object: "public.z_table" }, + { rule: "rls_disabled_in_public", object: "public.a_table" }, + { rule: "function_search_path_mutable", object: "public.z_function" }, + { rule: "function_search_path_mutable", object: "public.a_function" }, + ], + }); + }); +}); diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 845d69246b..92e6128c12 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -1828,6 +1828,146 @@ }, "description": "Ordered list of schema files, directories, or glob patterns that describe your database.", "default": [] + }, + "checks": { + "type": "object", + "properties": { + "rls_disabled_in_public": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_disabled_in_public: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "rls_enabled_no_policy": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_enabled_no_policy: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "policy_exists_rls_disabled": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for policy_exists_rls_disabled: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "security_definer_view": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for security_definer_view: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "rls_policy_always_true": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_policy_always_true: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "sensitive_columns_exposed": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for sensitive_columns_exposed: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "auth_users_exposed": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for auth_users_exposed: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "function_search_path_mutable": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for function_search_path_mutable: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "rls_references_user_metadata": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_references_user_metadata: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "exclude": { + "type": "object", + "properties": { + "rls_disabled_in_public": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_disabled_in_public skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "rls_enabled_no_policy": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_enabled_no_policy skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "policy_exists_rls_disabled": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects policy_exists_rls_disabled skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "security_definer_view": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects security_definer_view skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "rls_policy_always_true": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_policy_always_true skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "sensitive_columns_exposed": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects sensitive_columns_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "auth_users_exposed": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects auth_users_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "function_search_path_mutable": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects function_search_path_mutable skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "rls_references_user_metadata": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_references_user_metadata skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + } + }, + "additionalProperties": false, + "description": "Objects each rule skips, keyed by rule." + } + }, + "additionalProperties": false, + "description": "Security checks run on each migration. Each rule takes \"error\", \"warn\" or \"off\"; a rule left unset uses \"warn\". Runs against --db-url read this section from config.toml only." } }, "additionalProperties": false @@ -4518,6 +4658,146 @@ }, "description": "Ordered list of schema files, directories, or glob patterns that describe your database.", "default": [] + }, + "checks": { + "type": "object", + "properties": { + "rls_disabled_in_public": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_disabled_in_public: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "rls_enabled_no_policy": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_enabled_no_policy: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "policy_exists_rls_disabled": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for policy_exists_rls_disabled: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "security_definer_view": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for security_definer_view: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "rls_policy_always_true": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_policy_always_true: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "sensitive_columns_exposed": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for sensitive_columns_exposed: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "auth_users_exposed": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for auth_users_exposed: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "function_search_path_mutable": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for function_search_path_mutable: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "rls_references_user_metadata": { + "type": "string", + "enum": ["error", "warn", "off"], + "description": "Severity for rls_references_user_metadata: \"error\" will block the migration; until blocking ships it reports like \"warn\". \"warn\" reports it, \"off\" skips the rule.", + "default": "warn" + }, + "exclude": { + "type": "object", + "properties": { + "rls_disabled_in_public": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_disabled_in_public skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "rls_enabled_no_policy": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_enabled_no_policy skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "policy_exists_rls_disabled": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects policy_exists_rls_disabled skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "security_definer_view": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects security_definer_view skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "rls_policy_always_true": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_policy_always_true skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "sensitive_columns_exposed": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects sensitive_columns_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "auth_users_exposed": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects auth_users_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "function_search_path_mutable": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects function_search_path_mutable skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + }, + "rls_references_user_metadata": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + }, + "description": "Objects rls_references_user_metadata skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." + } + }, + "additionalProperties": false, + "description": "Objects each rule skips, keyed by rule." + } + }, + "additionalProperties": false, + "description": "Security checks run on each migration. Each rule takes \"error\", \"warn\" or \"off\"; a rule left unset uses \"warn\". Runs against --db-url read this section from config.toml only." } }, "additionalProperties": false diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 0b1bd35e73..fcbe93190a 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -52,6 +52,8 @@ artifacts (`./schema.json`, `./project-schema.json`). env record, then decode and validate, optionally on an overlaid document) — kept off `./effect`'s public surface deliberately (no consumer outside `apps/cli` needs them, and internal-only keeps the published semver surface unchanged). Anything here can change or vanish in any release. + Migration-check vocabulary for the CLI resolver: `MIGRATION_CHECK_RULES`, `mapMigrationCheckRules`, + `DEFAULT_MIGRATION_CHECK_SEVERITY`, `MigrationCheckRule`, and `MigrationCheckSeverity`. - `@supabase/config/schema.json` — generated JSON Schema (draft 2020-12) for `CliConfig` (a `dist/` build output). - `@supabase/config/project-schema.json` (CLI-2234) — generated JSON Schema (draft 2020-12) for diff --git a/packages/config/src/db-migration-checks.ts b/packages/config/src/db-migration-checks.ts new file mode 100644 index 0000000000..eff7dc7551 --- /dev/null +++ b/packages/config/src/db-migration-checks.ts @@ -0,0 +1,226 @@ +import { Schema, SchemaTransformation } from "effect"; + +/** The security rules `[db.migrations.checks]` accepts, named as in the database linter. */ +export const MIGRATION_CHECK_RULES = [ + "rls_disabled_in_public", + "rls_enabled_no_policy", + "policy_exists_rls_disabled", + "security_definer_view", + "rls_policy_always_true", + "sensitive_columns_exposed", + "auth_users_exposed", + "function_search_path_mutable", + "rls_references_user_metadata", +] as const; + +export type MigrationCheckRule = (typeof MIGRATION_CHECK_RULES)[number]; + +const SEVERITIES = ["error", "warn", "off"] as const; + +export type MigrationCheckSeverity = (typeof SEVERITIES)[number]; + +/** The severity used when a migration check rule is unset. */ +export const DEFAULT_MIGRATION_CHECK_SEVERITY = "warn"; +const SUGGESTION_MAX_DISTANCE = 3; +const tags = ["database"]; + +const linterDocs = (rule: MigrationCheckRule) => ({ + name: `Database linter: ${rule}`, + link: `https://supabase.com/docs/guides/database/database-linter?lint=${rule}`, +}); + +/** Builds an object with one entry per migration check rule. */ +export const mapMigrationCheckRules = ( + make: (rule: MigrationCheckRule) => A, +): { readonly [R in MigrationCheckRule]: A } => ({ + rls_disabled_in_public: make("rls_disabled_in_public"), + rls_enabled_no_policy: make("rls_enabled_no_policy"), + policy_exists_rls_disabled: make("policy_exists_rls_disabled"), + security_definer_view: make("security_definer_view"), + rls_policy_always_true: make("rls_policy_always_true"), + sensitive_columns_exposed: make("sensitive_columns_exposed"), + auth_users_exposed: make("auth_users_exposed"), + function_search_path_mutable: make("function_search_path_mutable"), + rls_references_user_metadata: make("rls_references_user_metadata"), +}); + +const Severity = Schema.Literals(SEVERITIES); + +// Only the first dot separates schema from name; whitespace is allowed inside either. +const ObjectName = Schema.String.check(Schema.isPattern(/^(?!\s)[^.]+\.[\s\S]*\S(?![\s\S])/u)); + +const TypedMigrationChecks = Schema.Struct({ + ...mapMigrationCheckRules((rule) => + Schema.optionalKey( + Severity.annotate({ + default: DEFAULT_MIGRATION_CHECK_SEVERITY, + description: `Severity for ${rule}: "error" will block the migration; until blocking ships it reports like "warn". "warn" reports it, "off" skips the rule.`, + tags, + links: [linterDocs(rule)], + }), + ), + ), + exclude: Schema.optionalKey( + Schema.Struct( + mapMigrationCheckRules((rule) => + Schema.optionalKey( + Schema.Array(ObjectName).annotate({ + description: `Objects ${rule} skips, each written "schema.name", for example "public.countries". An entry like "public.set_updated_at" matches every overload of that function.`, + tags, + links: [linterDocs(rule)], + }), + ), + ), + ).annotate({ + description: "Objects each rule skips, keyed by rule.", + tags, + }), + ), +}).annotate({ + description: + 'Security checks run on each migration. Each rule takes "error", "warn" or "off"; a rule left unset uses "warn". Runs against --db-url read this section from config.toml only.', + tags, +}); + +const editDistance = (a: string, b: string): number => { + let previous = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const current = [i]; + for (let j = 1; j <= b.length; j++) { + const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1); + current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution)); + } + previous = current; + } + return previous[b.length] ?? 0; +}; + +const closestKey = (key: string, candidates: ReadonlyArray): string | undefined => { + let best: { readonly rule: string; readonly distance: number } | undefined; + for (const rule of candidates) { + const distance = editDistance(key, rule); + if (distance <= SUGGESTION_MAX_DISTANCE && (best === undefined || distance < best.distance)) { + best = { rule, distance }; + } + } + return best?.rule; +}; + +const unknownRuleMessage = (key: string, section: string): string => { + const candidates = + section === "[db.migrations.checks]" + ? [...MIGRATION_CHECK_RULES, "exclude"] + : MIGRATION_CHECK_RULES; + const suggestion = closestKey(key, candidates); + return suggestion === undefined + ? `"${key}" is not a known rule in ${section}. Check the spelling, or upgrade the CLI if a teammate added it. Known rules: ${MIGRATION_CHECK_RULES.join(", ")}.` + : `Unknown ${suggestion === "exclude" ? "key" : "rule"} "${key}" in ${section}. Did you mean "${suggestion}"?`; +}; + +const isRule = (key: string): key is MigrationCheckRule => + MIGRATION_CHECK_RULES.some((rule) => rule === key); + +const isSeverity = (value: unknown): boolean => SEVERITIES.some((severity) => severity === value); + +const isPlainObject = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isObjectName = Schema.is(ObjectName); + +interface RawIssue { + readonly path: ReadonlyArray; + readonly issue: string; +} + +const severityIssues = (section: Readonly>): Array => + Object.entries(section).flatMap(([key, value]): Array => { + if (key === "exclude") return []; + if (!isRule(key)) { + return [{ path: [key], issue: unknownRuleMessage(key, "[db.migrations.checks]") }]; + } + return isSeverity(value) + ? [] + : [ + { + path: [key], + issue: `${key} must be "error", "warn" or "off" (got ${JSON.stringify(value)})`, + }, + ]; + }); + +const exclusionIssues = (exclude: unknown): Array => { + if (exclude === undefined) return []; + if (!isPlainObject(exclude)) { + return [{ path: ["exclude"], issue: "[db.migrations.checks.exclude] must be a table" }]; + } + return Object.entries(exclude).flatMap(([key, entries]): Array => { + const path = ["exclude", key]; + if (!isRule(key)) { + return [{ path, issue: unknownRuleMessage(key, "[db.migrations.checks.exclude]") }]; + } + if (!Array.isArray(entries)) { + return [ + { + path, + issue: + "values under [db.migrations.checks.exclude] must be lists; severities go above it", + }, + ]; + } + return entries.flatMap((entry, index) => + isObjectName(entry) + ? [] + : [ + { + path: [...path, index], + issue: `exclude entries are "schema.name", for example "public.countries" (got ${JSON.stringify(entry)})`, + }, + ], + ); + }); +}; + +const migrationChecksIssues = ( + section: Readonly>, +): ReadonlyArray | undefined => { + const issues = [...severityIssues(section), ...exclusionIssues(section["exclude"])]; + return issues.length === 0 ? undefined : issues; +}; + +// Decoding starts from an open record so the filter sees unknown keys, which the loader's +// excess-property mode would otherwise drop before validation. +const RawMigrationChecks = Schema.Record(Schema.String, Schema.Unknown).check( + Schema.makeFilter(migrationChecksIssues, { + toJsonSchema: () => + Schema.toJsonSchemaDocument(TypedMigrationChecks, { onExcessProperty: "error" }).schema, + }), +); + +const dedupeExclusions = ( + section: Readonly>, +): Readonly> => { + const exclude = section["exclude"]; + if (!isPlainObject(exclude)) return section; + return { + ...section, + exclude: Object.fromEntries( + Object.entries(exclude).map(([rule, entries]) => [ + rule, + Array.isArray(entries) ? [...new Set(entries)] : entries, + ]), + ), + }; +}; + +/** `[db.migrations.checks]`: per-rule severities and per-rule exclusions for migration checks. */ +export const MigrationChecks = RawMigrationChecks.pipe( + Schema.decodeTo( + TypedMigrationChecks, + SchemaTransformation.transform({ + decode: dedupeExclusions, + encode: (section) => section, + }), + ), +); + +export type MigrationChecks = typeof MigrationChecks.Type; diff --git a/packages/config/src/db-migration-checks.unit.test.ts b/packages/config/src/db-migration-checks.unit.test.ts new file mode 100644 index 0000000000..02a909c24d --- /dev/null +++ b/packages/config/src/db-migration-checks.unit.test.ts @@ -0,0 +1,233 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "vitest"; +import { Effect, FileSystem, Path, Schema, SchemaIssue } from "effect"; +import { loadCliConfigFile } from "./effect.ts"; + +const loadToml = Effect.fn("MigrationChecksTest.loadToml")(function* (section: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "migration-checks-" }); + yield* fs.makeDirectory(path.join(cwd, "supabase")); + const file = path.join(cwd, "supabase", "config.toml"); + yield* fs.writeFileString(file, `project_id = "checks-test"\n${section}\n`); + return yield* loadCliConfigFile(file); +}); + +const loadIssues = Effect.fn("MigrationChecksTest.loadIssues")(function* (section: string) { + const error = yield* Effect.flip(loadToml(section)); + if (error._tag !== "CliConfigParseError") { + return yield* Effect.die("Expected CliConfigParseError from config loading"); + } + const cause = error.cause; + if (!Schema.isSchemaError(cause)) { + return yield* Effect.die("Expected a schema error as the config parse cause"); + } + return SchemaIssue.makeFormatterStandardSchemaV1()(cause.issue).issues; +}); + +const knownRules = + "rls_disabled_in_public, rls_enabled_no_policy, policy_exists_rls_disabled, security_definer_view, rls_policy_always_true, sensitive_columns_exposed, auth_users_exposed, function_search_path_mutable, rls_references_user_metadata"; + +const diagnostics = [ + { + name: "suggests a misspelled rule", + toml: '[db.migrations.checks]\nrls_disabled_in_publc = "warn"', + path: ["db", "migrations", "checks", "rls_disabled_in_publc"], + message: + 'Unknown rule "rls_disabled_in_publc" in [db.migrations.checks]. Did you mean "rls_disabled_in_public"?', + }, + { + name: "suggests a rule at edit distance three", + toml: '[db.migrations.checks]\nrls_disabled_in_pub = "warn"', + path: ["db", "migrations", "checks", "rls_disabled_in_pub"], + message: + 'Unknown rule "rls_disabled_in_pub" in [db.migrations.checks]. Did you mean "rls_disabled_in_public"?', + }, + { + name: "lists known rules beyond edit distance three", + toml: '[db.migrations.checks]\nrls_disabled_in_pu = "warn"', + path: ["db", "migrations", "checks", "rls_disabled_in_pu"], + message: `"rls_disabled_in_pu" is not a known rule in [db.migrations.checks]. Check the spelling, or upgrade the CLI if a teammate added it. Known rules: ${knownRules}.`, + }, + { + name: "rejects a severity in place of the exclude table", + toml: '[db.migrations.checks]\nexclude = "warn"', + path: ["db", "migrations", "checks", "exclude"], + message: "[db.migrations.checks.exclude] must be a table", + }, + { + name: "names a non-string severity value", + toml: "[db.migrations.checks]\nrls_disabled_in_public = 5", + path: ["db", "migrations", "checks", "rls_disabled_in_public"], + message: 'rls_disabled_in_public must be "error", "warn" or "off" (got 5)', + }, + { + name: "escapes quotes in a malformed exclusion diagnostic", + toml: `[db.migrations.checks.exclude]\nrls_disabled_in_public = [' public."t']`, + path: ["db", "migrations", "checks", "exclude", "rls_disabled_in_public", 0], + message: + 'exclude entries are "schema.name", for example "public.countries" (got " public.\\"t")', + }, + { + name: "suggests the exclude table", + toml: "[db.migrations.checks.exlude]", + path: ["db", "migrations", "checks", "exlude"], + message: 'Unknown key "exlude" in [db.migrations.checks]. Did you mean "exclude"?', + }, + { + name: "lists known rules for an unknown severity key", + toml: '[db.migrations.checks]\nstorage_bucket_public = "warn"', + path: ["db", "migrations", "checks", "storage_bucket_public"], + message: `"storage_bucket_public" is not a known rule in [db.migrations.checks]. Check the spelling, or upgrade the CLI if a teammate added it. Known rules: ${knownRules}.`, + }, + { + name: "lists known rules for an unknown exclusion key", + toml: '[db.migrations.checks.exclude]\nstorage_bucket_public = ["public.t"]', + path: ["db", "migrations", "checks", "exclude", "storage_bucket_public"], + message: `"storage_bucket_public" is not a known rule in [db.migrations.checks.exclude]. Check the spelling, or upgrade the CLI if a teammate added it. Known rules: ${knownRules}.`, + }, + { + name: "suggests a misspelled exclusion rule", + toml: '[db.migrations.checks.exclude]\nrls_disabled_in_publc = ["public.t"]', + path: ["db", "migrations", "checks", "exclude", "rls_disabled_in_publc"], + message: + 'Unknown rule "rls_disabled_in_publc" in [db.migrations.checks.exclude]. Did you mean "rls_disabled_in_public"?', + }, + { + name: "names the rule and invalid severity", + toml: '[db.migrations.checks]\nrls_disabled_in_public = "eror"', + path: ["db", "migrations", "checks", "rls_disabled_in_public"], + message: 'rls_disabled_in_public must be "error", "warn" or "off" (got "eror")', + }, + { + name: "explains a severity placed below the exclude header", + toml: '[db.migrations.checks.exclude]\nrls_disabled_in_public = "warn"', + path: ["db", "migrations", "checks", "exclude", "rls_disabled_in_public"], + message: "values under [db.migrations.checks.exclude] must be lists; severities go above it", + }, +]; + +describe("db.migrations.checks TOML loading", () => { + it("leaves an absent checks section undefined", () => + Effect.gen(function* () { + const loaded = yield* loadToml(""); + expect(loaded.config.db.migrations.checks).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise)); + + it.each(diagnostics)("$name", ({ toml, path, message }) => + Effect.gen(function* () { + expect(yield* loadIssues(toml)).toEqual([{ path, message }]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), + ); + + it("reports several problems in the same file together", () => + Effect.gen(function* () { + const issues = yield* loadIssues( + '[db.migrations.checks]\nrls_disabled_in_publc = "warn"\nrls_disabled_in_public = "eror"\n[db.migrations.checks.exclude]\nrls_enabled_no_policy = "warn"', + ); + expect(issues).toEqual([ + { + path: ["db", "migrations", "checks", "rls_disabled_in_publc"], + message: + 'Unknown rule "rls_disabled_in_publc" in [db.migrations.checks]. Did you mean "rls_disabled_in_public"?', + }, + { + path: ["db", "migrations", "checks", "rls_disabled_in_public"], + message: 'rls_disabled_in_public must be "error", "warn" or "off" (got "eror")', + }, + { + path: ["db", "migrations", "checks", "exclude", "rls_enabled_no_policy"], + message: + "values under [db.migrations.checks.exclude] must be lists; severities go above it", + }, + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise)); + + it.each(["public.countries", "my schema.t", "public.my table", "a.b.c"])( + "accepts exclusion entry %j", + (entry) => + Effect.gen(function* () { + const loaded = yield* loadToml( + `[db.migrations.checks.exclude]\nrls_disabled_in_public = ["${entry}"]`, + ); + expect(loaded.config.db.migrations.checks).toEqual({ + exclude: { rls_disabled_in_public: [entry] }, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), + ); + + it.each(['" public.t"', '"public.t "', '"public"', '".t"', '"public."', '""', "42"])( + "rejects malformed exclusion entry %s and names its value and index", + (entry) => + Effect.gen(function* () { + const issues = yield* loadIssues( + `[db.migrations.checks.exclude]\nrls_disabled_in_public = ["public.valid", ${entry}]`, + ); + expect(issues).toEqual([ + { + path: ["db", "migrations", "checks", "exclude", "rls_disabled_in_public", 1], + message: `exclude entries are "schema.name", for example "public.countries" (got ${entry})`, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), + ); + + it("removes duplicate exclusions while keeping first-seen order", () => + Effect.gen(function* () { + const loaded = yield* loadToml( + '[db.migrations.checks.exclude]\nrls_disabled_in_public = ["public.b", "public.a", "public.b", "public.c", "public.a"]', + ); + expect(loaded.config.db.migrations.checks?.exclude?.rls_disabled_in_public).toEqual([ + "public.b", + "public.a", + "public.c", + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise)); + + it.each([ + "rls_disabled_in_public", + "rls_enabled_no_policy", + "policy_exists_rls_disabled", + "security_definer_view", + "rls_policy_always_true", + "sensitive_columns_exposed", + "auth_users_exposed", + "function_search_path_mutable", + "rls_references_user_metadata", + ])("preserves a severity and exclusion for %s", (rule) => + Effect.gen(function* () { + const loaded = yield* loadToml( + `[db.migrations.checks]\n${rule} = "error"\n[db.migrations.checks.exclude]\n${rule} = ["public.countries"]`, + ); + expect(loaded.config.db.migrations.checks).toEqual({ + [rule]: "error", + exclude: { [rule]: ["public.countries"] }, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), + ); + + it("preserves explicit severities and exclusions without filling unset rules", () => + Effect.gen(function* () { + const loaded = yield* loadToml( + '[db.migrations.checks]\nrls_disabled_in_public = "error"\nfunction_search_path_mutable = "off"\nsensitive_columns_exposed = "warn"\n[db.migrations.checks.exclude]\nrls_disabled_in_public = ["public.countries"]\nfunction_search_path_mutable = ["public.set_updated_at"]', + ); + expect(loaded.config.db.migrations.checks).toEqual({ + rls_disabled_in_public: "error", + function_search_path_mutable: "off", + sensitive_columns_exposed: "warn", + exclude: { + rls_disabled_in_public: ["public.countries"], + function_search_path_mutable: ["public.set_updated_at"], + }, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise)); + + it("ignores unknown keys outside checks", () => + Effect.gen(function* () { + const loaded = yield* loadToml( + '[db.migrations]\nunknown_migration_option = true\nenabled = false\n[db.migrations.checks]\nrls_disabled_in_public = "error"', + ); + expect(loaded.config.db.migrations).not.toHaveProperty("unknown_migration_option"); + expect(loaded.config.db.migrations.checks).toEqual({ rls_disabled_in_public: "error" }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise)); +}); diff --git a/packages/config/src/db.ts b/packages/config/src/db.ts index d78d17f759..ae51290b30 100644 --- a/packages/config/src/db.ts +++ b/packages/config/src/db.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect"; +import { MigrationChecks } from "./db-migration-checks.ts"; import { secret } from "./lib/env.ts"; import { stringEnum } from "./lib/schema.ts"; @@ -150,6 +151,7 @@ export const db = Schema.Struct({ tags, }) .pipe(Schema.withDecodingDefaultKey(Effect.succeed([...defaultSchemaPaths]))), + checks: Schema.optionalKey(MigrationChecks), }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultMigrations }))), seed: Schema.Struct({ enabled: Schema.Boolean.annotate({ diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 6131c7e8d9..0712298db3 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -326,6 +326,7 @@ const expectedPureGraphFiles = [ "auth/third_party.ts", "auth/web3.ts", "db.ts", + "db-migration-checks.ts", "edge_runtime.ts", "experimental.ts", "functions.ts", @@ -514,12 +515,15 @@ describe("src/internal.ts export surface", () => { [ "AUTH_HOOK_NAMES", "CliConfigWriteError", + "DEFAULT_MIGRATION_CHECK_SEVERITY", "ENV_CAPTURE_REGEX", + "MIGRATION_CHECK_RULES", "applyConfigEdits", "decodeCliConfigDocumentForValidationEffect", "decodeMergedCliConfig", "dualScopeProjectConfigPaths", "loadCliConfig", + "mapMigrationCheckRules", "mergeParsedCliConfig", "parseCliConfigDocumentFile", "projectConfigApiBlockKeys", diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 0a69a07379..7a983b8064 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -7,6 +7,13 @@ * internal-only `cliCompat` knob. */ export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; +export { + DEFAULT_MIGRATION_CHECK_SEVERITY, + MIGRATION_CHECK_RULES, + mapMigrationCheckRules, + type MigrationCheckRule, + type MigrationCheckSeverity, +} from "./db-migration-checks.ts"; export { type AppliedConfigEdit, applyConfigEdits, diff --git a/packages/config/src/json-schema-document.ts b/packages/config/src/json-schema-document.ts index 3b996c03a9..730f9d55b0 100644 --- a/packages/config/src/json-schema-document.ts +++ b/packages/config/src/json-schema-document.ts @@ -2,7 +2,8 @@ * Assembles a published JSON Schema document from a generated schema and its definitions. * Pattern-keyed records stay open: the generator now writes a boolean `additionalProperties` * on them (effect 4.0) and has no per-schema setting, while the published documents never - * carried one there. Structs keep theirs. + * carried one there. Structs keep theirs. A bare object whose only constraint is one object + * schema in `allOf` is replaced by that schema, so editors read its properties directly. */ export function toPublishedJsonSchemaDocument(document: { readonly schema: Record; @@ -25,7 +26,25 @@ function openPatternKeyedRecords(node: T): T { if (isRecordLike(out) && typeof out["additionalProperties"] === "boolean") { delete out["additionalProperties"]; } - return out as T; + return (singleObjectAllOf(out) ?? out) as T; +} + +function singleObjectAllOf(node: Record): Record | undefined { + const allOf = node["allOf"]; + if (Object.keys(node).length !== 2 || node["type"] !== "object") return undefined; + if (!Array.isArray(allOf) || allOf.length !== 1) return undefined; + const [member]: ReadonlyArray = allOf; + return isObjectSchema(member) ? member : undefined; +} + +function isObjectSchema(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "type" in value && + value.type === "object" + ); } function isRecordLike(node: Record): boolean { diff --git a/packages/config/src/json-schema-document.unit.test.ts b/packages/config/src/json-schema-document.unit.test.ts index efa0e87e9c..81674ed5ed 100644 --- a/packages/config/src/json-schema-document.unit.test.ts +++ b/packages/config/src/json-schema-document.unit.test.ts @@ -22,6 +22,42 @@ describe("published JSON Schema documents", () => { expect(at(project, "properties", "compute")).not.toHaveProperty("additionalProperties"); }); + test("migration checks publish a closed object with patterned exclusions", () => { + const checks = at( + toCliConfigJsonSchema(), + "properties", + "db", + "properties", + "migrations", + "properties", + "checks", + ); + const rules = [ + "rls_disabled_in_public", + "rls_enabled_no_policy", + "policy_exists_rls_disabled", + "security_definer_view", + "rls_policy_always_true", + "sensitive_columns_exposed", + "auth_users_exposed", + "function_search_path_mutable", + "rls_references_user_metadata", + ]; + expect(checks).not.toHaveProperty("allOf"); + expect(checks).toHaveProperty("additionalProperties", false); + expect(Object.keys(at(checks, "properties")).sort()).toEqual([...rules, "exclude"].sort()); + const exclude = at(checks, "properties", "exclude"); + expect(Object.keys(at(exclude, "properties")).sort()).toEqual([...rules].sort()); + for (const rule of rules) { + expect(at(checks, "properties", rule)).toMatchObject({ + type: "string", + enum: ["error", "warn", "off"], + }); + expect(at(exclude, "properties", rule)).toMatchObject({ type: "array" }); + expect(at(exclude, "properties", rule, "items").pattern).toEqual(expect.any(String)); + } + }); + test("empty bucket tables render as object or array", () => { const project = toProjectConfigJsonSchema(); const buckets = at( From be64a0ecf2d41309cc0bbbc0dd822e53a79c28f1 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 23 Oct 2026 11:24:16 -0600 Subject: [PATCH 2/4] test(config): cover migration checks in a selected remote and empty exclusion lists --- .../src/db-migration-checks.unit.test.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/config/src/db-migration-checks.unit.test.ts b/packages/config/src/db-migration-checks.unit.test.ts index 02a909c24d..f199beb1db 100644 --- a/packages/config/src/db-migration-checks.unit.test.ts +++ b/packages/config/src/db-migration-checks.unit.test.ts @@ -1,20 +1,27 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "vitest"; import { Effect, FileSystem, Path, Schema, SchemaIssue } from "effect"; +import type { LoadCliConfigOptions } from "./config-document.ts"; import { loadCliConfigFile } from "./effect.ts"; -const loadToml = Effect.fn("MigrationChecksTest.loadToml")(function* (section: string) { +const loadToml = Effect.fn("MigrationChecksTest.loadToml")(function* ( + section: string, + options?: LoadCliConfigOptions, +) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "migration-checks-" }); yield* fs.makeDirectory(path.join(cwd, "supabase")); const file = path.join(cwd, "supabase", "config.toml"); yield* fs.writeFileString(file, `project_id = "checks-test"\n${section}\n`); - return yield* loadCliConfigFile(file); + return yield* loadCliConfigFile(file, options); }); -const loadIssues = Effect.fn("MigrationChecksTest.loadIssues")(function* (section: string) { - const error = yield* Effect.flip(loadToml(section)); +const loadIssues = Effect.fn("MigrationChecksTest.loadIssues")(function* ( + section: string, + options?: LoadCliConfigOptions, +) { + const error = yield* Effect.flip(loadToml(section, options)); if (error._tag !== "CliConfigParseError") { return yield* Effect.die("Expected CliConfigParseError from config loading"); } @@ -172,6 +179,29 @@ describe("db.migrations.checks TOML loading", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), ); + it("accepts an empty exclusion list", () => + Effect.gen(function* () { + const loaded = yield* loadToml("[db.migrations.checks.exclude]\nrls_disabled_in_public = []"); + expect(loaded.config.db.migrations.checks).toEqual({ + exclude: { rls_disabled_in_public: [] }, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise)); + + it("rejects a misspelled rule in the selected remote block", () => + Effect.gen(function* () { + const issues = yield* loadIssues( + '[remotes.staging]\nproject_id = "abcdefghijklmnopqrst"\n[remotes.staging.db.migrations.checks]\nrls_disabled_in_publc = "off"', + { projectRef: "abcdefghijklmnopqrst" }, + ); + expect(issues).toEqual([ + { + path: ["db", "migrations", "checks", "rls_disabled_in_publc"], + message: + 'Unknown rule "rls_disabled_in_publc" in [db.migrations.checks]. Did you mean "rls_disabled_in_public"?', + }, + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise)); + it("removes duplicate exclusions while keeping first-seen order", () => Effect.gen(function* () { const loaded = yield* loadToml( From 46072c985b19d48e7d7949a653df23dd86985784 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 23 Oct 2026 11:33:59 -0600 Subject: [PATCH 3/4] fix(config): tighten migration check exclusion names Exclusion entries reject whitespace next to the schema separator, and the --db-url note in the section description is reworded. --- apps/docs/public/cli/config.schema.json | 40 +++++++++---------- packages/config/src/db-migration-checks.ts | 8 ++-- .../src/db-migration-checks.unit.test.ts | 36 ++++++++++------- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 92e6128c12..ee59febc55 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -1893,7 +1893,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_disabled_in_public skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1901,7 +1901,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_enabled_no_policy skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1909,7 +1909,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects policy_exists_rls_disabled skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1917,7 +1917,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects security_definer_view skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1925,7 +1925,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_policy_always_true skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1933,7 +1933,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects sensitive_columns_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1941,7 +1941,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects auth_users_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1949,7 +1949,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects function_search_path_mutable skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -1957,7 +1957,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_references_user_metadata skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." } @@ -1967,7 +1967,7 @@ } }, "additionalProperties": false, - "description": "Security checks run on each migration. Each rule takes \"error\", \"warn\" or \"off\"; a rule left unset uses \"warn\". Runs against --db-url read this section from config.toml only." + "description": "Security checks run on each migration. Each rule takes \"error\", \"warn\" or \"off\"; a rule left unset uses \"warn\". When migrations run against --db-url, this section is read only from the local config.toml." } }, "additionalProperties": false @@ -4723,7 +4723,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_disabled_in_public skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4731,7 +4731,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_enabled_no_policy skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4739,7 +4739,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects policy_exists_rls_disabled skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4747,7 +4747,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects security_definer_view skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4755,7 +4755,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_policy_always_true skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4763,7 +4763,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects sensitive_columns_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4771,7 +4771,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects auth_users_exposed skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4779,7 +4779,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects function_search_path_mutable skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." }, @@ -4787,7 +4787,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?!\\s)[^.]+\\.[\\s\\S]*\\S(?![\\s\\S])" + "pattern": "^(?!\\s)[^.]*[^.\\s]\\.(?!\\s)[\\s\\S]*\\S(?![\\s\\S])" }, "description": "Objects rls_references_user_metadata skips, each written \"schema.name\", for example \"public.countries\". An entry like \"public.set_updated_at\" matches every overload of that function." } @@ -4797,7 +4797,7 @@ } }, "additionalProperties": false, - "description": "Security checks run on each migration. Each rule takes \"error\", \"warn\" or \"off\"; a rule left unset uses \"warn\". Runs against --db-url read this section from config.toml only." + "description": "Security checks run on each migration. Each rule takes \"error\", \"warn\" or \"off\"; a rule left unset uses \"warn\". When migrations run against --db-url, this section is read only from the local config.toml." } }, "additionalProperties": false diff --git a/packages/config/src/db-migration-checks.ts b/packages/config/src/db-migration-checks.ts index eff7dc7551..7dc5f5fbfd 100644 --- a/packages/config/src/db-migration-checks.ts +++ b/packages/config/src/db-migration-checks.ts @@ -46,8 +46,10 @@ export const mapMigrationCheckRules = ( const Severity = Schema.Literals(SEVERITIES); -// Only the first dot separates schema from name; whitespace is allowed inside either. -const ObjectName = Schema.String.check(Schema.isPattern(/^(?!\s)[^.]+\.[\s\S]*\S(?![\s\S])/u)); +// Only the first dot separates schema from name; whitespace is allowed inside either, not at their edges. +const ObjectName = Schema.String.check( + Schema.isPattern(/^(?!\s)[^.]*[^.\s]\.(?!\s)[\s\S]*\S(?![\s\S])/u), +); const TypedMigrationChecks = Schema.Struct({ ...mapMigrationCheckRules((rule) => @@ -78,7 +80,7 @@ const TypedMigrationChecks = Schema.Struct({ ), }).annotate({ description: - 'Security checks run on each migration. Each rule takes "error", "warn" or "off"; a rule left unset uses "warn". Runs against --db-url read this section from config.toml only.', + 'Security checks run on each migration. Each rule takes "error", "warn" or "off"; a rule left unset uses "warn". When migrations run against --db-url, this section is read only from the local config.toml.', tags, }); diff --git a/packages/config/src/db-migration-checks.unit.test.ts b/packages/config/src/db-migration-checks.unit.test.ts index f199beb1db..408f1f13cd 100644 --- a/packages/config/src/db-migration-checks.unit.test.ts +++ b/packages/config/src/db-migration-checks.unit.test.ts @@ -163,20 +163,28 @@ describe("db.migrations.checks TOML loading", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), ); - it.each(['" public.t"', '"public.t "', '"public"', '".t"', '"public."', '""', "42"])( - "rejects malformed exclusion entry %s and names its value and index", - (entry) => - Effect.gen(function* () { - const issues = yield* loadIssues( - `[db.migrations.checks.exclude]\nrls_disabled_in_public = ["public.valid", ${entry}]`, - ); - expect(issues).toEqual([ - { - path: ["db", "migrations", "checks", "exclude", "rls_disabled_in_public", 1], - message: `exclude entries are "schema.name", for example "public.countries" (got ${entry})`, - }, - ]); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), + it.each([ + '" public.t"', + '"public.t "', + '"public .t"', + '"public. t"', + '"public"', + '".t"', + '"public."', + '""', + "42", + ])("rejects malformed exclusion entry %s and names its value and index", (entry) => + Effect.gen(function* () { + const issues = yield* loadIssues( + `[db.migrations.checks.exclude]\nrls_disabled_in_public = ["public.valid", ${entry}]`, + ); + expect(issues).toEqual([ + { + path: ["db", "migrations", "checks", "exclude", "rls_disabled_in_public", 1], + message: `exclude entries are "schema.name", for example "public.countries" (got ${entry})`, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer), Effect.runPromise), ); it("accepts an empty exclusion list", () => From 34b6be49a6fccf5da8d08b0800101749a9c49be1 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 26 Oct 2026 12:03:49 -0600 Subject: [PATCH 4/4] fix(config): apply migration check env overrides without the section --- apps/cli/src/config/cli-config-key-annotations.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/cli/src/config/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts index 9626928324..6bc084efda 100644 --- a/apps/cli/src/config/cli-config-key-annotations.ts +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -144,6 +144,10 @@ export const CLI_CONFIG_SECTION_ENV_EXEMPT: Readonly> = { "a plain struct in the document model: its keys exist whether or not it is written", "db.settings": "a plain struct in the document model: its keys exist whether or not it is written", + "db.migrations.checks": + 'every rule defaults to "warn" without the section, so an env severity must apply while it is absent', + "db.migrations.checks.exclude": + "an absent section means no exclusions, so an env exclusion list must apply while it is absent", "experimental.pgdelta": "enabled defaults to true without the section, so the env rollback must apply while it is absent", };