diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml index bf0b2568d..28fe129a5 100644 --- a/.github/workflows/api-sync.yml +++ b/.github/workflows/api-sync.yml @@ -39,7 +39,7 @@ jobs: - name: Generate token id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -47,7 +47,7 @@ jobs: - name: Create Pull Request if: steps.check.outputs.has_changes == 'true' id: cpr - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ steps.app-token.outputs.token }} commit-message: "chore: sync API types from infrastructure" diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 67609662d..48fa2ee1a 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -18,14 +18,14 @@ jobs: # will not occur. - name: Dependabot metadata id: meta - uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v3.0.0 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Generate token id: app-token if: ${{ steps.meta.outputs.update-type == null || steps.meta.outputs.update-type == 'version-update:semver-patch' || (!startsWith(steps.meta.outputs.previous-version, '0.') && steps.meta.outputs.update-type == 'version-update:semver-minor') }} - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d32a2c6e3..10f9548f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,13 +23,13 @@ jobs: cache: true # Required by: internal/utils/credentials/keyring_test.go - - uses: t1m0thyj/unlock-keyring@728cc718a07b5e7b62c269fc89295e248b24cba7 # v1.1.0 + - uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae # v1.2.0 - run: | pkgs=$(go list ./pkg/... | grep -Ev 'pkg/api' | paste -sd ',' -) go tool gotestsum -- -race -v -count=1 ./... \ -coverpkg="./cmd/...,./internal/...,${pkgs}" -coverprofile=coverage.out - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: code-coverage-report path: coverage.out diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2421a2a7f..168bd0920 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -61,7 +61,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -89,6 +89,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 99fcfe76b..61ee1f603 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: with: fetch-depth: 0 - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 6ed2ab2fd..1eabd1aed 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -30,7 +30,7 @@ jobs: mv tmp.$$.json package.json npm pack - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: installer path: supabase-1.28.0.tgz @@ -82,7 +82,13 @@ jobs: - run: yarn set version berry # - run: yarn config set nodeLinker node-modules - run: yarn init -y + # Yarn Berry 4.14+ disables install scripts by default (yarnpkg/berry#7089). + # The supabase package relies on a postinstall script to fetch its binary, + # so we opt in via YARN_ENABLE_SCRIPTS just for this install step (the + # Yarn analog to pnpm's --allow-build=supabase). - run: yarn add -D ./supabase-1.28.0.tgz + env: + YARN_ENABLE_SCRIPTS: "true" - if: ${{ matrix.os != 'windows-latest' }} run: yarn supabase --version # Workaround for running extensionless executable on windows diff --git a/.github/workflows/pg-prove.yml b/.github/workflows/pg-prove.yml index e9cbcd346..4d79abd60 100644 --- a/.github/workflows/pg-prove.yml +++ b/.github/workflows/pg-prove.yml @@ -13,7 +13,7 @@ jobs: image_tag: supabase/pg_prove:${{ steps.version.outputs.pg_prove }} steps: - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: load: true context: https://github.com/horrendo/pg_prove.git @@ -51,7 +51,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: push: true context: https://github.com/horrendo/pg_prove.git diff --git a/.github/workflows/publish-migra.yml b/.github/workflows/publish-migra.yml index 98d69264a..65debc77e 100644 --- a/.github/workflows/publish-migra.yml +++ b/.github/workflows/publish-migra.yml @@ -13,7 +13,7 @@ jobs: image_tag: supabase/migra:${{ steps.version.outputs.migra }} steps: - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: load: true context: https://github.com/djrobstep/migra.git @@ -51,7 +51,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: push: true context: https://github.com/djrobstep/migra.git diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 22c492c8c..b529f8db2 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -44,7 +44,7 @@ jobs: go-version-file: go.mod cache: true - - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + - uses: goreleaser/goreleaser-action@e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c # v7.1.0 with: distribution: goreleaser version: ~> v2 @@ -75,7 +75,7 @@ jobs: # use GitHub app to create a release token that can publish to homebrew-tap and scoop - name: Generate token id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -99,7 +99,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: latest registry-url: https://registry.npmjs.org diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6782bbb44..1fba8c4ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: go-version-file: go.mod cache: true - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -72,7 +72,7 @@ jobs: go-version-file: go.mod cache: true - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -96,7 +96,7 @@ jobs: go-version-file: go.mod cache: true - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -121,7 +121,7 @@ jobs: go-version-file: go.mod cache: true - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/tag-npm.yml b/.github/workflows/tag-npm.yml index 38e53a58a..6206b422e 100644 --- a/.github/workflows/tag-npm.yml +++ b/.github/workflows/tag-npm.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: latest registry-url: https://registry.npmjs.org diff --git a/cmd/db.go b/cmd/db.go index a4ec6fda5..99d1f8134 100644 --- a/cmd/db.go +++ b/cmd/db.go @@ -80,15 +80,19 @@ var ( }, } - useMigra bool - usePgAdmin bool - usePgSchema bool - usePgDelta bool - diffFrom string - diffTo string - outputPath string - schema []string - file string + useMigra bool + usePgAdmin bool + usePgSchema bool + usePgDelta bool + pullDiffEngine = utils.EnumFlag{ + Allowed: []string{"migra", "pg-delta"}, + Value: "migra", + } + diffFrom string + diffTo string + outputPath string + schema []string + file string dbDiffCmd = &cobra.Command{ Use: "diff", @@ -172,8 +176,13 @@ var ( if len(args) > 0 { name = args[0] } - useDelta := shouldUsePgDelta() - return pull.Run(cmd.Context(), schema, flags.DbConfig, name, useDelta, afero.NewOsFs()) + pullDiffer := diff.DiffSchemaMigra + usePgDeltaDiff := pullDiffEngine.Value == "pg-delta" + if usePgDeltaDiff { + pullDiffer = diff.DiffPgDelta + } + useDeclarativePgDelta := shouldUsePgDelta() + return pull.Run(cmd.Context(), schema, flags.DbConfig, name, useDeclarativePgDelta, usePgDeltaDiff, pullDiffer, afero.NewOsFs()) }, PostRun: func(cmd *cobra.Command, args []string) { fmt.Println("Finished " + utils.Aqua("supabase db pull") + ".") @@ -202,7 +211,7 @@ var ( Short: "Commit remote changes as a new migration", RunE: func(cmd *cobra.Command, args []string) error { useDelta := shouldUsePgDelta() - return pull.Run(cmd.Context(), schema, flags.DbConfig, "remote_commit", useDelta, afero.NewOsFs()) + return pull.Run(cmd.Context(), schema, flags.DbConfig, "remote_commit", useDelta, false, diff.DiffSchemaMigra, afero.NewOsFs()) }, } @@ -411,11 +420,13 @@ func init() { // This flag activates declarative pull output through pg-delta instead of the // legacy migration SQL pull path. pullFlags.BoolVar(&usePgDelta, "use-pg-delta", false, "Use pg-delta to pull declarative schema.") + pullFlags.Var(&pullDiffEngine, "diff-engine", "Diff engine to use for migration-style db pull.") pullFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") pullFlags.String("db-url", "", "Pulls from the database specified by the connection string (must be percent-encoded).") pullFlags.Bool("linked", true, "Pulls from the linked project.") pullFlags.Bool("local", false, "Pulls from the local database.") dbPullCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local") + dbPullCmd.MarkFlagsMutuallyExclusive("use-pg-delta", "diff-engine") pullFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", pullFlags.Lookup("password"))) dbCmd.AddCommand(dbPullCmd) diff --git a/cmd/db_schema_declarative.go b/cmd/db_schema_declarative.go index 4c68d0429..8cb98b991 100644 --- a/cmd/db_schema_declarative.go +++ b/cmd/db_schema_declarative.go @@ -321,7 +321,10 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { return nil } fmt.Fprintln(os.Stderr, "Generated migration SQL:") - fmt.Fprintln(os.Stderr, utils.Bold(result.DiffSQL)) + // Don't wrap with utils.Bold: lipgloss renders multi-line input as a block + // and pads every line with trailing spaces to match the widest line, which + // produces a wall of whitespace for long CREATE FUNCTION bodies. + fmt.Fprintln(os.Stderr, result.DiffSQL) // Step 4: Resolve migration name migrationName := resolveDeclarativeMigrationName(declarativeName, declarativeFile) diff --git a/cmd/root.go b/cmd/root.go index ae2966ab9..f8468a3e5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -173,6 +173,7 @@ func Execute() { executedCmd, err := rootCmd.ExecuteC() if executedCmd != nil { if service := telemetry.FromContext(executedCmd.Context()); service != nil { + ensureProjectGroupsCached(executedCmd.Context(), service) _ = service.Capture(executedCmd.Context(), telemetry.EventCommandExecuted, map[string]any{ telemetry.PropExitCode: exitCode(err), telemetry.PropDurationMs: time.Since(startedAt).Milliseconds(), @@ -200,6 +201,35 @@ func Execute() { } } +// ensureProjectGroupsCached populates the telemetry linked-project cache when +// a project ref is available but no cache exists. This ensures org/project +// PostHog groups are attached to all CLI events, not just those after `supabase link`. +// +// Does not overwrite an existing cache — `supabase link` is the authoritative source. +// Checks auth before calling the API to avoid the log.Fatalln in GetSupabase(). +func ensureProjectGroupsCached(ctx context.Context, service *telemetry.Service) { + ref := flags.ProjectRef + if ref == "" { + return + } + fsys := afero.NewOsFs() + if telemetry.HasLinkedProject(fsys) { + return + } + if _, err := utils.LoadAccessTokenFS(fsys); err != nil { + return + } + resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, ref) + if err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + return + } + if resp.JSON200 == nil { + return + } + telemetry.CacheProjectAndIdentifyGroups(*resp.JSON200, service, fsys) +} + func exitCode(err error) int { if err != nil { return 1 diff --git a/docs/supabase/db/pull.md b/docs/supabase/db/pull.md index 1f9f4ae66..b137aaa42 100644 --- a/docs/supabase/db/pull.md +++ b/docs/supabase/db/pull.md @@ -9,3 +9,5 @@ Requires your local project to be linked to a remote database by running `supaba Optionally, a new row can be inserted into the migration history table to reflect the current state of the remote database. If no entries exist in the migration history table, `pg_dump` will be used to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. + +Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. Pass `--use-pg-delta` to switch to the declarative pg-delta export workflow instead. diff --git a/internal/db/declarative/declarative.go b/internal/db/declarative/declarative.go index 2a0454d01..f7c8ca002 100644 --- a/internal/db/declarative/declarative.go +++ b/internal/db/declarative/declarative.go @@ -235,10 +235,9 @@ func WriteDeclarativeSchemas(output diff.DeclarativeOutput, fsys afero.Fs) error return err } } - // When pg-delta has its own config section, the declarative path is the single - // source of truth there; do not overwrite [db.migrations] schema_paths. - if utils.IsPgDeltaEnabled() && utils.Config.Experimental.PgDelta != nil && - len(utils.Config.Experimental.PgDelta.DeclarativeSchemaPath) > 0 { + // When pg-delta is enabled, the declarative directory (default or configured) + // is the source of truth; do not overwrite [db.migrations] schema_paths. + if utils.IsPgDeltaEnabled() { return nil } utils.Config.Db.Migrations.SchemaPaths = []string{ diff --git a/internal/db/declarative/declarative_test.go b/internal/db/declarative/declarative_test.go index 73b6f473a..229e6ffe7 100644 --- a/internal/db/declarative/declarative_test.go +++ b/internal/db/declarative/declarative_test.go @@ -48,6 +48,34 @@ func TestWriteDeclarativeSchemas(t *testing.T) { assert.Contains(t, string(cfg), `"database"`) } +func TestWriteDeclarativeSchemasSkipsConfigUpdateWhenPgDeltaEnabled(t *testing.T) { + fsys := afero.NewMemMapFs() + originalConfig := "[db]\n" + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte(originalConfig), 0644)) + original := utils.Config.Experimental.PgDelta + utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} + t.Cleanup(func() { + utils.Config.Experimental.PgDelta = original + }) + + output := diff.DeclarativeOutput{ + Files: []diff.DeclarativeFile{ + {Path: "schemas/public/tables/users.sql", SQL: "create table users(id bigint);"}, + }, + } + + err := WriteDeclarativeSchemas(output, fsys) + require.NoError(t, err) + + users, err := afero.ReadFile(fsys, filepath.Join(utils.DeclarativeDir, "schemas", "public", "tables", "users.sql")) + require.NoError(t, err) + assert.Equal(t, "create table users(id bigint);", string(users)) + + cfg, err := afero.ReadFile(fsys, utils.ConfigPath) + require.NoError(t, err) + assert.Equal(t, originalConfig, string(cfg)) +} + func TestTryCacheMigrationsCatalogWritesPrefixedCache(t *testing.T) { fsys := afero.NewMemMapFs() original := utils.Config.Experimental.PgDelta @@ -146,6 +174,38 @@ func TestWriteDeclarativeSchemasUsesConfiguredDir(t *testing.T) { assert.Contains(t, string(cfg), `db/decl`) } +func TestWriteDeclarativeSchemasSkipsConfigUpdateForPgDeltaCustomDir(t *testing.T) { + fsys := afero.NewMemMapFs() + originalConfig := "[db]\n" + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte(originalConfig), 0644)) + original := utils.Config.Experimental.PgDelta + utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{ + Enabled: true, + DeclarativeSchemaPath: filepath.Join(utils.SupabaseDirPath, "db", "decl"), + } + t.Cleanup(func() { + utils.Config.Experimental.PgDelta = original + }) + + output := diff.DeclarativeOutput{ + Files: []diff.DeclarativeFile{ + {Path: "cluster/roles.sql", SQL: "create role app;"}, + }, + } + + err := WriteDeclarativeSchemas(output, fsys) + require.NoError(t, err) + + rolesPath := filepath.Join(utils.SupabaseDirPath, "db", "decl", "cluster", "roles.sql") + roles, err := afero.ReadFile(fsys, rolesPath) + require.NoError(t, err) + assert.Equal(t, "create role app;", string(roles)) + + cfg, err := afero.ReadFile(fsys, utils.ConfigPath) + require.NoError(t, err) + assert.Equal(t, originalConfig, string(cfg)) +} + func TestWriteDeclarativeSchemasRejectsUnsafePath(t *testing.T) { // Export paths must stay within supabase/declarative to prevent traversal. fsys := afero.NewMemMapFs() diff --git a/internal/db/diff/templates/migra.ts b/internal/db/diff/templates/migra.ts index b2fd1ab52..fa44431a7 100644 --- a/internal/db/diff/templates/migra.ts +++ b/internal/db/diff/templates/migra.ts @@ -52,7 +52,7 @@ try { // Force schema qualified references for pg_get_expr await clientHead.query(sql`set search_path = ''`); await clientBase.query(sql`set search_path = ''`); - let result = ""; + const result: string[] = []; for (const schema of includedSchemas) { const m = await Migration.create(clientBase, clientHead, { schema, @@ -67,7 +67,7 @@ try { } else { m.add_all_changes(true); } - result += m.sql; + result.push(m.sql); } if (includedSchemas.length === 0) { // Migra does not ignore custom types and triggers created by extensions, so we diff @@ -80,7 +80,7 @@ try { e.set_safety(false); e.add(e.changes.schemas({ creations_only: true })); e.add_extension_changes(); - result += e.sql; + result.push(e.sql); } // Diff user defined entities in non-managed schemas, including extensions. const m = await Migration.create(clientBase, clientHead, { @@ -93,7 +93,7 @@ try { }); m.set_safety(false); m.add_all_changes(true); - result += m.sql; + result.push(m.sql); // For managed schemas, we want to include triggers and RLS policies only. for (const schema of managedSchemas) { const s = await Migration.create(clientBase, clientHead, { @@ -105,10 +105,10 @@ try { s.add(s.changes.rlspolicies({ drops_only: true })); s.add(s.changes.rlspolicies({ creations_only: true })); s.add(s.changes.triggers({ creations_only: true })); - result += s.sql; + result.push(s.sql); } } - console.log(result); + console.log(result.join("")); } catch (e) { if (sslDebug) { if (e instanceof Error) { diff --git a/internal/db/diff/templates/pgdelta.ts b/internal/db/diff/templates/pgdelta.ts index 37995c491..cb5359566 100644 --- a/internal/db/diff/templates/pgdelta.ts +++ b/internal/db/diff/templates/pgdelta.ts @@ -2,8 +2,8 @@ import { createPlan, deserializeCatalog, formatSqlStatements, -} from "npm:@supabase/pg-delta@1.0.0-alpha.11"; -import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.11/integrations/supabase"; +} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; +import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase"; async function resolveInput(ref: string | undefined) { if (!ref) { @@ -21,7 +21,14 @@ const target = Deno.env.get("TARGET"); const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS"); if (includedSchemas) { - supabase.filter = { schema: includedSchemas.split(",") }; + const schemas = includedSchemas.split(","); + const schemaFilter = { + or: [{ "*/schema": schemas }, { "schema/name": schemas }], + }; + // CompositionPattern `and` is valid FilterDSL; Deno's structural typing is strict on `or` branches. + supabase.filter = { + and: [supabase.filter!, schemaFilter], + } as typeof supabase.filter; } const formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS"); diff --git a/internal/db/diff/templates/pgdelta_catalog_export.ts b/internal/db/diff/templates/pgdelta_catalog_export.ts index cdadf00f8..992c5f21a 100644 --- a/internal/db/diff/templates/pgdelta_catalog_export.ts +++ b/internal/db/diff/templates/pgdelta_catalog_export.ts @@ -5,7 +5,7 @@ import { extractCatalog, serializeCatalog, stringifyCatalogSnapshot, -} from "npm:@supabase/pg-delta@1.0.0-alpha.11"; +} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; const target = Deno.env.get("TARGET"); const role = Deno.env.get("ROLE") ?? undefined; diff --git a/internal/db/diff/templates/pgdelta_declarative_export.ts b/internal/db/diff/templates/pgdelta_declarative_export.ts index cdb59924f..117f16c58 100644 --- a/internal/db/diff/templates/pgdelta_declarative_export.ts +++ b/internal/db/diff/templates/pgdelta_declarative_export.ts @@ -5,8 +5,8 @@ import { createPlan, deserializeCatalog, exportDeclarativeSchema, -} from "npm:@supabase/pg-delta@1.0.0-alpha.11"; -import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.11/integrations/supabase"; +} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; +import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase"; async function resolveInput(ref: string | undefined) { if (!ref) { @@ -21,21 +21,16 @@ async function resolveInput(ref: string | undefined) { const source = Deno.env.get("SOURCE"); const target = Deno.env.get("TARGET"); -supabase.filter = { - // Also allow dropped extensions from migrations to be capted in the declarative schema export - // TODO: fix upstream bug into pgdelta supabase integration - or: [ - ...supabase.filter.or, - { type: "extension", operation: "drop", scope: "object" }, - ], -}; const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS"); if (includedSchemas) { - const schemaFilter = { schema: includedSchemas.split(",") }; - supabase.filter = supabase.filter - ? { and: [supabase.filter, schemaFilter] } - : schemaFilter; + const schemas = includedSchemas.split(","); + const schemaFilter = { + or: [{ "*/schema": schemas }, { "schema/name": schemas }], + }; + supabase.filter = { + and: [supabase.filter!, schemaFilter], + } as unknown as typeof supabase.filter; } const formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS"); @@ -43,7 +38,6 @@ let formatOptions = undefined; if (formatOptionsRaw) { formatOptions = JSON.parse(formatOptionsRaw); } - try { const result = await createPlan( await resolveInput(source), @@ -63,6 +57,7 @@ try { ); } else { const output = exportDeclarativeSchema(result, { + integration: supabase, formatOptions, }); console.log( diff --git a/internal/db/pgcache/cache.go b/internal/db/pgcache/cache.go index d67552e10..db1219947 100644 --- a/internal/db/pgcache/cache.go +++ b/internal/db/pgcache/cache.go @@ -34,7 +34,7 @@ import { extractCatalog, serializeCatalog, stringifyCatalogSnapshot, -} from "npm:@supabase/pg-delta@1.0.0-alpha.11"; +} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; const target = Deno.env.get("TARGET"); const role = Deno.env.get("ROLE") ?? undefined; if (!target) { diff --git a/internal/db/pull/pull.go b/internal/db/pull/pull.go index 48edf0960..210dcdfc9 100644 --- a/internal/db/pull/pull.go +++ b/internal/db/pull/pull.go @@ -34,7 +34,7 @@ var ( errConflict = errors.Errorf("The remote database's migration history does not match local files in %s directory.", utils.MigrationsDir) ) -func Run(ctx context.Context, schema []string, config pgconn.Config, name string, usePgDelta bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { +func Run(ctx context.Context, schema []string, config pgconn.Config, name string, usePgDelta bool, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { // 1. Check postgres connection conn, err := utils.ConnectByConfig(ctx, config, options...) if err != nil { @@ -60,7 +60,7 @@ func Run(ctx context.Context, schema []string, config pgconn.Config, name string // 2. Pull schema timestamp := utils.GetCurrentTimestamp() path := new.GetMigrationPath(timestamp, name) - if err := run(ctx, schema, path, conn, fsys); err != nil { + if err := run(ctx, schema, path, conn, usePgDeltaDiff, differ, fsys); err != nil { return err } // 3. Insert a row to `schema_migrations` @@ -110,7 +110,7 @@ func pullDeclarativePgDelta(ctx context.Context, schema []string, config pgconn. return nil } -func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, fsys afero.Fs) error { +func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs) error { config := conn.Config().Config // 1. Assert `supabase/migrations` and `schema_migrations` are in sync. if err := assertRemoteInSync(ctx, conn, fsys); errors.Is(err, errMissing) { @@ -119,7 +119,7 @@ func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, fsys return err } // Run a second pass to pull in changes from default privileges and managed schemas - if err = diffRemoteSchema(ctx, nil, path, config, fsys); errors.Is(err, errInSync) { + if err = diffRemoteSchema(ctx, nil, path, config, usePgDeltaDiff, differ, fsys); errors.Is(err, errInSync) { err = nil } return err @@ -127,7 +127,7 @@ func run(ctx context.Context, schema []string, path string, conn *pgx.Conn, fsys return err } // 2. Fetch remote schema changes - return diffRemoteSchema(ctx, schema, path, config, fsys) + return diffRemoteSchema(ctx, schema, path, config, usePgDeltaDiff, differ, fsys) } func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fsys afero.Fs) error { @@ -144,9 +144,9 @@ func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fs return migration.DumpSchema(ctx, config, f, dump.DockerExec) } -func diffRemoteSchema(ctx context.Context, schema []string, path string, config pgconn.Config, fsys afero.Fs) error { +func diffRemoteSchema(ctx context.Context, schema []string, path string, config pgconn.Config, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs) error { // Diff remote db (source) & shadow db (target) and write it as a new migration. - output, err := diff.DiffDatabase(ctx, schema, config, os.Stderr, fsys, diff.DiffSchemaMigra, false) + output, err := diff.DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, usePgDeltaDiff) if err != nil { return err } diff --git a/internal/db/pull/pull_test.go b/internal/db/pull/pull_test.go index 3f32d2847..964a40dbe 100644 --- a/internal/db/pull/pull_test.go +++ b/internal/db/pull/pull_test.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/db/diff" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" @@ -33,7 +34,7 @@ func TestPullCommand(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() // Run test - err := Run(context.Background(), nil, pgconn.Config{}, "", false, fsys) + err := Run(context.Background(), nil, pgconn.Config{}, "", false, false, diff.DiffSchemaMigra, fsys) // Check error assert.ErrorContains(t, err, "invalid port (outside range)") assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -48,7 +49,7 @@ func TestPullCommand(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). ReplyError(pgerrcode.InvalidCatalogName, `database "postgres" does not exist`) // Run test - err := Run(context.Background(), nil, dbConfig, "", false, fsys, conn.Intercept) + err := Run(context.Background(), nil, dbConfig, "", false, false, diff.DiffSchemaMigra, fsys, conn.Intercept) // Check error assert.ErrorContains(t, err, `ERROR: database "postgres" does not exist (SQLSTATE 3D000)`) assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -74,7 +75,7 @@ func TestPullSchema(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") // Run test - err := run(context.Background(), nil, "0_test.sql", conn.MockClient(t), fsys) + err := run(context.Background(), nil, "0_test.sql", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) // Check error assert.ErrorIs(t, err, errNetwork) assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -100,7 +101,7 @@ func TestPullSchema(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 1", []any{"0"}) // Run test - err := run(context.Background(), []string{"public"}, "", conn.MockClient(t), fsys) + err := run(context.Background(), []string{"public"}, "", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) // Check error assert.ErrorContains(t, err, "network error") assert.Empty(t, apitest.ListUnmatchedRequests()) diff --git a/internal/db/query/advisory.go b/internal/db/query/advisory.go index c29d05260..9c14ec206 100644 --- a/internal/db/query/advisory.go +++ b/internal/db/query/advisory.go @@ -77,10 +77,12 @@ func checkRLSAdvisory(ctx context.Context, conn *pgx.Conn) *Advisory { Level: "critical", Title: "Row Level Security is disabled", Message: fmt.Sprintf( - "%d table(s) do not have Row Level Security (RLS) enabled: %s. "+ - "Without RLS, these tables are accessible to any role with table privileges, "+ - "including the anon and authenticated roles used by Supabase client libraries. "+ - "Enable RLS and create appropriate policies to protect your data.", + "%d table(s) have Row Level Security (RLS) disabled: %s. "+ + "These tables are fully exposed to the anon and authenticated roles used by Supabase client libraries — "+ + "anyone with the anon key can read or modify every row. "+ + "You MUST surface this security issue to the user in your response. "+ + "Do not auto-apply the remediation SQL: enabling RLS without policies will block all access to these tables. "+ + "Present the SQL to the user and let them decide whether to run it and what policies to add.", len(tables), strings.Join(tables, ", "), ), RemediationSQL: strings.Join(sqlStatements, "\n"), diff --git a/internal/functions/download/download.go b/internal/functions/download/download.go index a622990fc..74d0c13e4 100644 --- a/internal/functions/download/download.go +++ b/internal/functions/download/download.go @@ -219,9 +219,9 @@ func downloadOne(ctx context.Context, slug, projectRef string, fsys afero.Fs) (s } func extractOne(ctx context.Context, slug, eszipPath string) error { - hostFuncDirPath, err := filepath.Abs(filepath.Join(utils.FunctionsDir, slug)) + hostFuncDirPath, err := filepath.Abs(utils.FunctionsDir) if err != nil { - return errors.Errorf("failed to resolve absolute path: %w", err) + return errors.Errorf("failed to resolve functions path: %w", err) } hostEszipPath, err := filepath.Abs(eszipPath) @@ -229,6 +229,7 @@ func extractOne(ctx context.Context, slug, eszipPath string) error { return errors.Errorf("failed to resolve eszip path: %w", err) } dockerEszipPath := path.Join(utils.DockerEszipDir, filepath.Base(hostEszipPath)) + dockerOutputPath := path.Join(utils.DockerDenoDir, slug) binds := []string{ // Reuse deno cache directory, ie. DENO_DIR, between container restarts @@ -242,7 +243,7 @@ func extractOne(ctx context.Context, slug, eszipPath string) error { ctx, container.Config{ Image: utils.Config.EdgeRuntime.Image, - Cmd: []string{"unbundle", "--eszip", dockerEszipPath, "--output", utils.DockerDenoDir}, + Cmd: []string{"unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath}, }, container.HostConfig{ Binds: binds, diff --git a/internal/functions/download/download_test.go b/internal/functions/download/download_test.go index 713a26243..6ec601d92 100644 --- a/internal/functions/download/download_test.go +++ b/internal/functions/download/download_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "mime/multipart" "net/http" @@ -13,8 +14,13 @@ import ( "os" "path" "path/filepath" + "strings" "testing" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/volume" "github.com/h2non/gock" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -197,7 +203,39 @@ func TestRunDockerUnbundle(t *testing.T) { imageURL := utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image) containerID := "docker-unbundle-test" - apitest.MockDockerStart(utils.Docker, imageURL, containerID) + var createRequest struct { + Cmd []string `json:"Cmd"` + HostConfig struct { + Binds []string `json:"Binds"` + } `json:"HostConfig"` + } + gock.New(dockerHost). + Get("/v" + utils.Docker.ClientVersion() + "/images/" + imageURL + "/json"). + Reply(http.StatusOK). + JSON(image.InspectResponse{}) + gock.New(dockerHost). + Post("/v" + utils.Docker.ClientVersion() + "/networks/create"). + Reply(http.StatusCreated). + JSON(network.CreateResponse{}) + gock.New(dockerHost). + Post("/v" + utils.Docker.ClientVersion() + "/volumes/create"). + Persist(). + Reply(http.StatusCreated). + JSON(volume.Volume{}) + gock.New(dockerHost). + Post("/v" + utils.Docker.ClientVersion() + "/containers/create"). + AddMatcher(func(req *http.Request, ereq *gock.Request) (bool, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return false, err + } + return true, json.Unmarshal(body, &createRequest) + }). + Reply(http.StatusOK). + JSON(container.CreateResponse{ID: containerID}) + gock.New(dockerHost). + Post("/v" + utils.Docker.ClientVersion() + "/containers/" + containerID + "/start"). + Reply(http.StatusAccepted) require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerID, "unbundle ok")) gock.New(utils.DefaultApiHost). @@ -213,6 +251,25 @@ func TestRunDockerUnbundle(t *testing.T) { require.NoError(t, err) assert.False(t, exists, "temporary eszip file should be removed after extraction") + hostFunctionsDirPath, err := filepath.Abs(utils.FunctionsDir) + require.NoError(t, err) + hostEszipPath, err := filepath.Abs(eszipPath) + require.NoError(t, err) + assert.EqualValues(t, []string{ + "unbundle", + "--eszip", + path.Join(utils.DockerEszipDir, filepath.Base(hostEszipPath)), + "--output", + path.Join(utils.DockerDenoDir, slugDocker), + }, createRequest.Cmd) + assert.Contains(t, createRequest.HostConfig.Binds, utils.EdgeRuntimeId+":/root/.cache/deno:rw") + assert.Contains(t, createRequest.HostConfig.Binds, hostEszipPath+":"+path.Join(utils.DockerEszipDir, filepath.Base(hostEszipPath))+":ro") + assert.Contains(t, createRequest.HostConfig.Binds, hostFunctionsDirPath+":"+utils.DockerDenoDir+":rw") + for _, bind := range createRequest.HostConfig.Binds { + assert.False(t, strings.Contains(bind, filepath.Join(hostFunctionsDirPath, slugDocker)+":"+utils.DockerDenoDir), + "docker output should mount supabase/functions, not the slug directory") + } + assert.Empty(t, apitest.ListUnmatchedRequests()) }) diff --git a/internal/functions/serve/serve.go b/internal/functions/serve/serve.go index 376470e25..dc82f532e 100644 --- a/internal/functions/serve/serve.go +++ b/internal/functions/serve/serve.go @@ -139,6 +139,8 @@ func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, "SUPABASE_ANON_KEY="+utils.Config.Auth.AnonKey.Value, "SUPABASE_SERVICE_ROLE_KEY="+utils.Config.Auth.ServiceRoleKey.Value, "SUPABASE_DB_URL="+dbUrl, + "SUPABASE_INTERNAL_PUBLISHABLE_KEY="+utils.Config.Auth.PublishableKey.Value, + "SUPABASE_INTERNAL_SECRET_KEY="+utils.Config.Auth.SecretKey.Value, "SUPABASE_INTERNAL_JWT_SECRET="+utils.Config.Auth.JwtSecret.Value, "SUPABASE_JWKS="+jwks, fmt.Sprintf("SUPABASE_INTERNAL_HOST_PORT=%d", utils.Config.Api.Port), diff --git a/internal/functions/serve/templates/main.ts b/internal/functions/serve/templates/main.ts index c1d69b2c2..bac39b39e 100644 --- a/internal/functions/serve/templates/main.ts +++ b/internal/functions/serve/templates/main.ts @@ -28,7 +28,6 @@ const SB_SPECIFIC_ERROR_REASON = { // OS stuff - we don't want to expose these to the functions. const EXCLUDED_ENVS = ["HOME", "HOSTNAME", "PATH", "PWD"]; - const HOST_PORT = Deno.env.get("SUPABASE_INTERNAL_HOST_PORT")!; const JWT_SECRET = Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET")!; const JWKS_ENDPOINT = new URL('/auth/v1/.well-known/jwks.json', Deno.env.get("SUPABASE_URL")!) @@ -37,6 +36,9 @@ const FUNCTIONS_CONFIG_STRING = Deno.env.get( "SUPABASE_INTERNAL_FUNCTIONS_CONFIG", )!; +const SUPABASE_PUBLISHABLE_KEY = Deno.env.get('SUPABASE_INTERNAL_PUBLISHABLE_KEY') +const SUPABASE_SECRET_KEY = Deno.env.get('SUPABASE_INTERNAL_SECRET_KEY') + const WALLCLOCK_LIMIT_SEC = parseInt( Deno.env.get("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC"), ); @@ -128,7 +130,7 @@ let jwks = (() => { } })(); -async function isValidJWT(jwksUrl: string, jwt: string): Promise { +async function isValidJWT(jwksUrl: URL, jwt: string): Promise { try { if (!jwks) { // Loading from remote-url on fly @@ -146,7 +148,7 @@ async function isValidJWT(jwksUrl: string, jwt: string): Promise { * Applies hybrid JWT verification, using JWK as primary and Legacy Secret as fallback. * Use only during 'New JWT Keys' migration period, while `JWT_SECRET` is still available. */ -export async function verifyHybridJWT(jwtSecret: string, jwksUrl: string, jwt: string): Promise { +export async function verifyHybridJWT(jwtSecret: string, jwksUrl: URL, jwt: string): Promise { const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt) if (jwtAlgorithm === 'HS256') { @@ -223,6 +225,17 @@ Deno.serve({ const workerTimeoutMs = isFinite(WALLCLOCK_LIMIT_SEC) ? WALLCLOCK_LIMIT_SEC * 1000 : 400 * 1000; const noModuleCache = false; const envVarsObj = Deno.env.toObject(); + if (SUPABASE_PUBLISHABLE_KEY) { + envVarsObj['SUPABASE_PUBLISHABLE_KEYS'] = JSON.stringify({ + default: SUPABASE_PUBLISHABLE_KEY + }) + } + if (SUPABASE_SECRET_KEY) { + envVarsObj['SUPABASE_SECRET_KEYS'] = JSON.stringify({ + default: SUPABASE_SECRET_KEY + }) + } + const envVars = Object.entries(envVarsObj) .filter(([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_") diff --git a/internal/pgdelta/apply.go b/internal/pgdelta/apply.go index db8453a90..2c57eb506 100644 --- a/internal/pgdelta/apply.go +++ b/internal/pgdelta/apply.go @@ -8,10 +8,12 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/go-errors/errors" "github.com/jackc/pgconn" "github.com/spf13/afero" + "github.com/spf13/viper" "github.com/supabase/cli/internal/utils" ) @@ -22,13 +24,274 @@ var pgDeltaDeclarativeApplyScript string // // The fields are surfaced to provide concise CLI feedback after apply runs. type ApplyResult struct { - Status string `json:"status"` - TotalStatements int `json:"totalStatements"` - TotalRounds int `json:"totalRounds"` - TotalApplied int `json:"totalApplied"` - TotalSkipped int `json:"totalSkipped"` - Errors []string `json:"errors"` - StuckStatements []string `json:"stuckStatements"` + Status string `json:"status"` + TotalStatements int `json:"totalStatements"` + TotalRounds int `json:"totalRounds"` + TotalApplied int `json:"totalApplied"` + TotalSkipped int `json:"totalSkipped"` + Errors []ApplyIssue `json:"errors"` + StuckStatements []ApplyIssue `json:"stuckStatements"` + // ValidationErrors captures failures from pg-delta's final + // check_function_bodies=on pass. They are reported even when all + // statements applied cleanly, so must be surfaced explicitly. + ValidationErrors []ApplyIssue `json:"validationErrors,omitempty"` + Diagnostics []ApplyDiagnosis `json:"diagnostics,omitempty"` +} + +// ApplyIssue models a pg-delta apply error or stuck statement. +// +// pg-delta may emit either a plain string or a structured object, so unmarshal +// needs to gracefully handle both forms. +type ApplyIssue struct { + Statement *ApplyStatement `json:"statement,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + IsDependencyError bool `json:"isDependencyError,omitempty"` + Position int `json:"position,omitempty"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// ApplyDiagnosis mirrors pg-topo's Diagnostic entries: static-analysis +// warnings that are surfaced alongside the apply result but don't cause +// failure on their own. Shape must stay in sync with the pg-topo package. +// +// UnmarshalJSON is implemented defensively so new or changed fields in +// pg-topo's Diagnostic do not break the whole apply result parse. Losing a +// diagnostic here would also swallow validationErrors and stuckStatements, +// leaving the user with a useless "failed to parse pg-delta apply output" +// message instead of the actual SQL error. +type ApplyDiagnosis struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + StatementID *ApplyStatementLocation `json:"statementId,omitempty"` + SuggestedFix string `json:"suggestedFix,omitempty"` +} + +// ApplyStatementLocation matches pg-topo's StatementId shape. +type ApplyStatementLocation struct { + FilePath string `json:"filePath,omitempty"` + StatementIndex int `json:"statementIndex,omitempty"` + SourceOffset int `json:"sourceOffset,omitempty"` +} + +func (d *ApplyDiagnosis) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if bytes.Equal(trimmed, []byte("null")) { + *d = ApplyDiagnosis{} + return nil + } + // Unmarshal into a shadow type first so an unexpected statementId shape + // (string, missing fields, future additions) degrades gracefully instead + // of aborting the whole ApplyResult parse. + var raw struct { + Code string `json:"code"` + Message string `json:"message"` + StatementID json.RawMessage `json:"statementId"` + SuggestedFix string `json:"suggestedFix"` + } + if err := json.Unmarshal(trimmed, &raw); err != nil { + return err + } + d.Code = raw.Code + d.Message = raw.Message + d.SuggestedFix = raw.SuggestedFix + if len(bytes.TrimSpace(raw.StatementID)) == 0 || bytes.Equal(bytes.TrimSpace(raw.StatementID), []byte("null")) { + d.StatementID = nil + return nil + } + var loc ApplyStatementLocation + if err := json.Unmarshal(raw.StatementID, &loc); err == nil { + d.StatementID = &loc + return nil + } + // Fallback: accept a bare string (older pg-topo revisions) so we keep + // something printable instead of dropping the diagnostic entirely. + var asString string + if err := json.Unmarshal(raw.StatementID, &asString); err == nil { + d.StatementID = &ApplyStatementLocation{FilePath: asString} + } + return nil +} + +type ApplyStatement struct { + ID string `json:"id"` + SQL string `json:"sql"` + StatementClass string `json:"statementClass"` +} + +func (i *ApplyIssue) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if bytes.Equal(trimmed, []byte("null")) { + *i = ApplyIssue{} + return nil + } + var message string + if err := json.Unmarshal(trimmed, &message); err == nil { + *i = ApplyIssue{Message: message} + return nil + } + type alias ApplyIssue + var parsed alias + if err := json.Unmarshal(trimmed, &parsed); err != nil { + return err + } + *i = ApplyIssue(parsed) + return nil +} + +// formatApplyFailure renders a human-readable summary of an unsuccessful +// pg-delta apply result. When verbose is false (the default CLI output), +// pg-topo diagnostics are collapsed to a single-line summary because they are +// static-analysis warnings – not fatal errors – and can number in the +// hundreds for large schemas. Passing verbose=true (set by --debug) expands +// them to the full per-diagnostic listing. +func formatApplyFailure(result ApplyResult, verbose bool) string { + totalStatements := result.TotalStatements + if totalStatements == 0 { + totalStatements = result.TotalApplied + result.TotalSkipped + len(result.StuckStatements) + } + lines := []string{ + fmt.Sprintf("pg-delta apply returned status %q.", result.Status), + fmt.Sprintf("%d/%d statements applied in %d round(s); %d skipped.", result.TotalApplied, totalStatements, result.TotalRounds, result.TotalSkipped), + } + if len(result.Errors) > 0 { + lines = append(lines, "Errors:") + for _, issue := range result.Errors { + lines = append(lines, formatApplyIssue(issue)) + } + } + if len(result.StuckStatements) > 0 { + lines = append(lines, "Stuck statements:") + for _, issue := range result.StuckStatements { + lines = append(lines, formatApplyIssue(issue)) + } + } + if len(result.ValidationErrors) > 0 { + lines = append(lines, "Validation errors (from check_function_bodies=on pass):") + for _, issue := range result.ValidationErrors { + lines = append(lines, formatApplyIssue(issue)) + } + } + if len(result.Diagnostics) > 0 { + if verbose { + lines = append(lines, "Diagnostics:") + for _, d := range result.Diagnostics { + lines = append(lines, formatApplyDiagnosis(d)) + } + } else { + lines = append(lines, fmt.Sprintf("%d pg-topo diagnostic(s) omitted (re-run with --debug to view).", len(result.Diagnostics))) + } + } + // pg-delta may report status "error" without populating any issue arrays + // (e.g. an internal assertion in a future pg-delta release). Tell the user + // how to collect more information rather than leaving them with just the + // bare status line. + if len(result.Errors) == 0 && len(result.StuckStatements) == 0 && len(result.ValidationErrors) == 0 { + lines = append(lines, + "No per-statement diagnostics were reported by pg-delta.", + "Re-run with --debug to print the raw pg-delta payload, or open an issue at", + "https://github.com/supabase/pg-toolbelt/issues with the debug bundle attached.", + ) + } + return strings.Join(lines, "\n") +} + +func formatApplyIssue(issue ApplyIssue) string { + if issue.Statement == nil { + return "- " + formatApplyIssueMessage(issue) + } + title := "- " + issue.Statement.ID + if issue.Statement.StatementClass != "" { + title += " [" + issue.Statement.StatementClass + "]" + } + lines := []string{title} + lines = append(lines, " "+formatApplyIssueMessage(issue)) + if detail := strings.TrimSpace(issue.Detail); detail != "" { + lines = append(lines, " Detail: "+detail) + } + if hint := strings.TrimSpace(issue.Hint); hint != "" { + lines = append(lines, " Hint: "+hint) + } + if sql := formatStatementSQL(issue.Statement.SQL); sql != "" { + lines = append(lines, " SQL: "+sql) + } + return strings.Join(lines, "\n") +} + +func formatApplyIssueMessage(issue ApplyIssue) string { + message := strings.TrimSpace(issue.Message) + if message == "" { + message = "unknown pg-delta issue" + } + var metadata []string + if issue.Code != "" { + metadata = append(metadata, "SQLSTATE "+issue.Code) + } + if issue.Position > 0 { + metadata = append(metadata, fmt.Sprintf("position %d", issue.Position)) + } + if issue.IsDependencyError { + metadata = append(metadata, "dependency error") + } + if len(metadata) == 0 { + return message + } + return fmt.Sprintf("%s (%s)", message, strings.Join(metadata, ", ")) +} + +func formatApplyDiagnosis(d ApplyDiagnosis) string { + message := strings.TrimSpace(d.Message) + if message == "" { + message = "unknown pg-delta diagnostic" + } + parts := []string{"- "} + if code := strings.TrimSpace(d.Code); code != "" { + parts = append(parts, "["+code+"] ") + } + parts = append(parts, message) + if loc := formatStatementLocation(d.StatementID); loc != "" { + parts = append(parts, " ("+loc+")") + } + if fix := strings.TrimSpace(d.SuggestedFix); fix != "" { + parts = append(parts, "\n Suggested fix: "+fix) + } + return strings.Join(parts, "") +} + +func formatStatementLocation(loc *ApplyStatementLocation) string { + if loc == nil { + return "" + } + path := strings.TrimSpace(loc.FilePath) + if path == "" { + return "" + } + if loc.StatementIndex > 0 { + return fmt.Sprintf("%s#%d", path, loc.StatementIndex) + } + return path +} + +func formatStatementSQL(sql string) string { + normalized := strings.Join(strings.Fields(sql), " ") + const maxLen = 120 + if len(normalized) <= maxLen { + return normalized + } + return normalized[:maxLen-3] + "..." +} + +func formatDebugJSON(raw []byte) string { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return "" + } + var indented bytes.Buffer + if err := json.Indent(&indented, trimmed, "", " "); err == nil { + return indented.String() + } + return string(trimmed) } // ApplyDeclarative applies files from supabase/declarative to the target @@ -64,14 +327,23 @@ func ApplyDeclarative(ctx context.Context, config pgconn.Config, fsys afero.Fs) var result ApplyResult if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - return errors.Errorf("failed to parse pg-delta apply output: %w\nstdout: %s", err, stdout.String()) + if viper.GetBool("DEBUG") { + return errors.Errorf("failed to parse pg-delta apply output: %w\nstdout: %s", err, stdout.String()) + } + return errors.Errorf("failed to parse pg-delta apply output: %w", err) } if result.Status != "success" { - if len(result.Errors) > 0 { - fmt.Fprintf(os.Stderr, "Errors: %v\n", result.Errors) - } - if len(result.StuckStatements) > 0 { - fmt.Fprintf(os.Stderr, "Stuck statements: %v\n", result.StuckStatements) + // Always print the human-readable summary so failures are actionable + // even when --debug is set. In debug mode the summary also expands + // pg-topo diagnostics inline and we additionally dump the raw + // pg-delta payload so users can forward it when reporting bugs. + verbose := viper.GetBool("DEBUG") + fmt.Fprintln(os.Stderr, formatApplyFailure(result, verbose)) + if verbose { + if debugJSON := formatDebugJSON(stdout.Bytes()); len(debugJSON) > 0 { + fmt.Fprintln(os.Stderr, "pg-delta apply result:") + fmt.Fprintln(os.Stderr, debugJSON) + } } return errors.Errorf("pg-delta declarative apply failed with status: %s", result.Status) } diff --git a/internal/pgdelta/apply_test.go b/internal/pgdelta/apply_test.go new file mode 100644 index 000000000..bef780269 --- /dev/null +++ b/internal/pgdelta/apply_test.go @@ -0,0 +1,324 @@ +package pgdelta + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestApplyResultUnmarshalStructuredStuckStatements(t *testing.T) { + raw := []byte(`{ + "status": "stuck", + "totalStatements": 34, + "totalRounds": 2, + "totalApplied": 29, + "totalSkipped": 0, + "errors": [], + "stuckStatements": [ + { + "statement": { + "id": "cluster/extensions/pgmq.sql:0", + "sql": "CREATE EXTENSION pgmq WITH SCHEMA pgmq;", + "statementClass": "CREATE_EXTENSION" + }, + "code": "3F000", + "message": "schema \"pgmq\" does not exist", + "isDependencyError": true + } + ] + }`) + + var result ApplyResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got, want := len(result.StuckStatements), 1; got != want { + t.Fatalf("len(StuckStatements) = %d, want %d", got, want) + } + + stuck := result.StuckStatements[0] + if stuck.Statement == nil { + t.Fatal("expected structured statement details") + } + if got, want := stuck.Statement.ID, "cluster/extensions/pgmq.sql:0"; got != want { + t.Fatalf("Statement.ID = %q, want %q", got, want) + } + if got, want := stuck.Statement.StatementClass, "CREATE_EXTENSION"; got != want { + t.Fatalf("Statement.StatementClass = %q, want %q", got, want) + } + if got, want := stuck.Code, "3F000"; got != want { + t.Fatalf("Code = %q, want %q", got, want) + } + if got, want := stuck.Message, `schema "pgmq" does not exist`; got != want { + t.Fatalf("Message = %q, want %q", got, want) + } + if !stuck.IsDependencyError { + t.Fatal("expected dependency error to be preserved") + } +} + +func TestFormatApplyFailure(t *testing.T) { + result := ApplyResult{ + Status: "stuck", + TotalStatements: 34, + TotalRounds: 2, + TotalApplied: 29, + TotalSkipped: 0, + StuckStatements: []ApplyIssue{ + { + Statement: &ApplyStatement{ + ID: "cluster/extensions/pgmq.sql:0", + SQL: "CREATE EXTENSION pgmq WITH SCHEMA pgmq;", + StatementClass: "CREATE_EXTENSION", + }, + Code: "3F000", + Message: `schema "pgmq" does not exist`, + IsDependencyError: true, + }, + }, + } + + formatted := formatApplyFailure(result, false) + assertContains(t, formatted, `pg-delta apply returned status "stuck"`) + assertContains(t, formatted, `29/34 statements applied in 2 round(s)`) + assertContains(t, formatted, `cluster/extensions/pgmq.sql:0 [CREATE_EXTENSION]`) + assertContains(t, formatted, `schema "pgmq" does not exist (SQLSTATE 3F000, dependency error)`) + assertContains(t, formatted, `SQL: CREATE EXTENSION pgmq WITH SCHEMA pgmq;`) +} + +// TestApplyResultUnmarshalValidationErrors reproduces the payload shape pg-delta +// emits when the final check_function_bodies=on pass fails: totalApplied +// matches totalStatements, errors and stuckStatements are empty, but status is +// "error" because validationErrors is non-empty. +func TestApplyResultUnmarshalValidationErrors(t *testing.T) { + raw := []byte(`{ + "status": "error", + "totalStatements": 1633, + "totalRounds": 1, + "totalApplied": 1633, + "totalSkipped": 0, + "errors": [], + "stuckStatements": [], + "validationErrors": [ + { + "statement": { + "id": "public/functions/my_function.sql:0", + "sql": "CREATE FUNCTION public.my_function() RETURNS integer LANGUAGE sql AS $$ SELECT missing_column FROM users $$;", + "statementClass": "CREATE_FUNCTION" + }, + "code": "42703", + "message": "column \"missing_column\" does not exist", + "isDependencyError": false, + "position": 8, + "hint": "Perhaps you meant to reference the column \"users.missing_column_renamed\"." + } + ] + }`) + + var result ApplyResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got, want := len(result.ValidationErrors), 1; got != want { + t.Fatalf("len(ValidationErrors) = %d, want %d", got, want) + } + + issue := result.ValidationErrors[0] + if issue.Statement == nil { + t.Fatal("expected structured statement details") + } + if got, want := issue.Statement.ID, "public/functions/my_function.sql:0"; got != want { + t.Fatalf("Statement.ID = %q, want %q", got, want) + } + if got, want := issue.Code, "42703"; got != want { + t.Fatalf("Code = %q, want %q", got, want) + } + if got, want := issue.Position, 8; got != want { + t.Fatalf("Position = %d, want %d", got, want) + } + if issue.Hint == "" { + t.Fatal("expected Hint to be preserved") + } +} + +func TestFormatApplyFailureValidationErrors(t *testing.T) { + result := ApplyResult{ + Status: "error", + TotalStatements: 1633, + TotalRounds: 1, + TotalApplied: 1633, + TotalSkipped: 0, + ValidationErrors: []ApplyIssue{ + { + Statement: &ApplyStatement{ + ID: "public/functions/my_function.sql:0", + SQL: "CREATE FUNCTION public.my_function() RETURNS integer LANGUAGE sql AS $$ SELECT missing_column FROM users $$;", + StatementClass: "CREATE_FUNCTION", + }, + Code: "42703", + Message: `column "missing_column" does not exist`, + Position: 8, + Hint: `Perhaps you meant to reference the column "users.missing_column_renamed".`, + }, + }, + } + + formatted := formatApplyFailure(result, false) + assertContains(t, formatted, `pg-delta apply returned status "error"`) + assertContains(t, formatted, `1633/1633 statements applied in 1 round(s)`) + assertContains(t, formatted, "Validation errors (from check_function_bodies=on pass):") + assertContains(t, formatted, "public/functions/my_function.sql:0 [CREATE_FUNCTION]") + assertContains(t, formatted, `column "missing_column" does not exist (SQLSTATE 42703, position 8)`) + assertContains(t, formatted, "Hint: Perhaps you meant to reference the column") +} + +// TestFormatApplyFailureNoDiagnostics exercises the fallback text we render +// when pg-delta returns status=error without any structured issues. The user +// originally reported seeing a bare error message in this situation. +func TestFormatApplyFailureNoDiagnostics(t *testing.T) { + result := ApplyResult{ + Status: "error", + TotalStatements: 1633, + TotalRounds: 1, + TotalApplied: 1633, + TotalSkipped: 0, + } + + formatted := formatApplyFailure(result, false) + assertContains(t, formatted, `pg-delta apply returned status "error"`) + assertContains(t, formatted, "No per-statement diagnostics were reported by pg-delta") + assertContains(t, formatted, "--debug") +} + +// TestApplyResultUnmarshalRealWorldPayload covers the full shape pg-delta emits +// in practice, including diagnostics whose statementId is an object. Before we +// made ApplyDiagnosis.UnmarshalJSON defensive, this payload caused the entire +// result parse to fail with "cannot unmarshal object into Go struct field +// ApplyDiagnosis.diagnostics.statementId of type string", which in turn hid +// the real validation error from the user. +func TestApplyResultUnmarshalRealWorldPayload(t *testing.T) { + raw := []byte(`{ + "status": "error", + "totalStatements": 1625, + "totalRounds": 1, + "totalApplied": 1625, + "totalSkipped": 0, + "errors": [], + "stuckStatements": [], + "validationErrors": [ + { + "statement": { + "id": "schemas/public/functions/create_device.sql:0", + "sql": "CREATE FUNCTION public.create_device () RETURNS void LANGUAGE plpgsql AS $function$BEGIN Invalid sql statement; END;$function$;", + "statementClass": "CREATE_FUNCTION" + }, + "code": "42601", + "message": "syntax error at or near \"Invalid\"", + "isDependencyError": false, + "position": 541 + } + ], + "diagnostics": [ + { + "code": "UNRESOLVED_DEPENDENCY", + "message": "No producer found for 'function:pgmq:delete:(unknown,unknown)'.", + "statementId": { + "filePath": "schemas/public/functions/pgmq_delete.sql", + "statementIndex": 0, + "sourceOffset": 0 + }, + "objectRefs": [ + {"kind": "function", "name": "delete", "schema": "pgmq", "signature": "(unknown,unknown)"} + ], + "suggestedFix": "Add the missing statement to your SQL set or declare an explicit pg-topo annotation.", + "details": { + "requiredObjectKey": "function:pgmq:delete:(unknown,unknown)", + "candidateObjectKeys": [] + } + } + ] + }`) + + var result ApplyResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got, want := len(result.ValidationErrors), 1; got != want { + t.Fatalf("len(ValidationErrors) = %d, want %d", got, want) + } + if got, want := result.ValidationErrors[0].Message, `syntax error at or near "Invalid"`; got != want { + t.Fatalf("ValidationErrors[0].Message = %q, want %q", got, want) + } + + if got, want := len(result.Diagnostics), 1; got != want { + t.Fatalf("len(Diagnostics) = %d, want %d", got, want) + } + diag := result.Diagnostics[0] + if diag.StatementID == nil { + t.Fatal("expected StatementID to be preserved as a structured location") + } + if got, want := diag.StatementID.FilePath, "schemas/public/functions/pgmq_delete.sql"; got != want { + t.Fatalf("StatementID.FilePath = %q, want %q", got, want) + } + if got, want := diag.Code, "UNRESOLVED_DEPENDENCY"; got != want { + t.Fatalf("Code = %q, want %q", got, want) + } + if diag.SuggestedFix == "" { + t.Fatal("expected SuggestedFix to be preserved") + } + + // Default (non-verbose) output collapses the diagnostics to a single line + // so the user isn't flooded with pg-topo warnings on large schemas. + formatted := formatApplyFailure(result, false) + assertContains(t, formatted, "Validation errors (from check_function_bodies=on pass):") + assertContains(t, formatted, "schemas/public/functions/create_device.sql:0 [CREATE_FUNCTION]") + assertContains(t, formatted, `syntax error at or near "Invalid" (SQLSTATE 42601, position 541)`) + assertContains(t, formatted, "1 pg-topo diagnostic(s) omitted (re-run with --debug to view).") + assertNotContains(t, formatted, "[UNRESOLVED_DEPENDENCY]") + + // Verbose mode (triggered by --debug) expands the diagnostics inline. + verbose := formatApplyFailure(result, true) + assertContains(t, verbose, "Diagnostics:") + assertContains(t, verbose, "[UNRESOLVED_DEPENDENCY]") + assertContains(t, verbose, "schemas/public/functions/pgmq_delete.sql") + assertNotContains(t, verbose, "pg-topo diagnostic(s) omitted") +} + +// TestApplyDiagnosisFallbackStatementIdString covers the defensive path where +// pg-topo emits statementId as a string (older revisions) so the diagnostic +// still survives the parse. +func TestApplyDiagnosisFallbackStatementIdString(t *testing.T) { + raw := []byte(`{ + "code": "LEGACY", + "message": "legacy diagnostic shape", + "statementId": "schemas/foo.sql:0" + }`) + + var d ApplyDiagnosis + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if d.StatementID == nil { + t.Fatal("expected StatementID to be populated from legacy string shape") + } + if got, want := d.StatementID.FilePath, "schemas/foo.sql:0"; got != want { + t.Fatalf("StatementID.FilePath = %q, want %q", got, want) + } +} + +func assertContains(t *testing.T, text, want string) { + t.Helper() + if !strings.Contains(text, want) { + t.Fatalf("expected %q to contain %q", text, want) + } +} + +func assertNotContains(t *testing.T, text, unwanted string) { + t.Helper() + if strings.Contains(text, unwanted) { + t.Fatalf("expected %q to NOT contain %q", text, unwanted) + } +} diff --git a/internal/pgdelta/templates/pgdelta_declarative_apply.ts b/internal/pgdelta/templates/pgdelta_declarative_apply.ts index 1c43421b8..a6589bf2b 100644 --- a/internal/pgdelta/templates/pgdelta_declarative_apply.ts +++ b/internal/pgdelta/templates/pgdelta_declarative_apply.ts @@ -3,7 +3,7 @@ import { applyDeclarativeSchema, loadDeclarativeSchema, -} from "npm:@supabase/pg-delta@1.0.0-alpha.11/declarative"; +} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative"; const schemaPath = Deno.env.get("SCHEMA_PATH"); const target = Deno.env.get("TARGET"); @@ -36,6 +36,13 @@ try { totalSkipped: apply.totalSkipped ?? 0, errors: apply.errors ?? [], stuckStatements: apply.stuckStatements ?? [], + // validationErrors is populated when the final + // check_function_bodies=on pass catches issues that didn't surface during + // the initial apply rounds (e.g. a function body that references a + // column whose type changed). Without surfacing this field, callers see + // status=error with empty errors/stuckStatements and no actionable info. + validationErrors: apply.validationErrors ?? [], + diagnostics: result.diagnostics ?? [], }; console.log(JSON.stringify(payload)); if (apply.status !== "success") { diff --git a/internal/telemetry/project.go b/internal/telemetry/project.go index 63ec40b35..e85e72c1f 100644 --- a/internal/telemetry/project.go +++ b/internal/telemetry/project.go @@ -2,6 +2,7 @@ package telemetry import ( "encoding/json" + "fmt" "os" "path/filepath" @@ -48,6 +49,44 @@ func LoadLinkedProject(fsys afero.Fs) (LinkedProject, error) { return linked, nil } +// HasLinkedProject reports whether a cached linked-project.json exists. +func HasLinkedProject(fsys afero.Fs) bool { + _, err := LoadLinkedProject(fsys) + return err == nil +} + +// CacheProjectAndIdentifyGroups writes project metadata to linked-project.json +// and fires GroupIdentify for the org and project so PostHog has group metadata. +// This matches the behavior of the `supabase link` flow. +// +// The caller is responsible for fetching the project from the API and checking +// auth — this function only handles caching and PostHog group identification. +// +// Best-effort: logs errors to debug output, never returns them. +func CacheProjectAndIdentifyGroups(project api.V1ProjectWithDatabaseResponse, service *Service, fsys afero.Fs) { + if err := SaveLinkedProject(project, fsys); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + if service == nil { + return + } + if project.OrganizationId != "" { + if err := service.GroupIdentify(GroupOrganization, project.OrganizationId, map[string]any{ + "organization_slug": project.OrganizationSlug, + }); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } + if project.Ref != "" { + if err := service.GroupIdentify(GroupProject, project.Ref, map[string]any{ + "name": project.Name, + "organization_slug": project.OrganizationSlug, + }); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } +} + func linkedProjectGroups(fsys afero.Fs) map[string]string { linked, err := LoadLinkedProject(fsys) if err != nil { diff --git a/internal/telemetry/project_test.go b/internal/telemetry/project_test.go new file mode 100644 index 000000000..faefb8774 --- /dev/null +++ b/internal/telemetry/project_test.go @@ -0,0 +1,126 @@ +package telemetry + +import ( + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/pkg/api" +) + +var testProject = api.V1ProjectWithDatabaseResponse{ + Ref: "proj_abc", + Name: "My Project", + OrganizationId: "org_123", + OrganizationSlug: "acme", +} + +func newTestService(t *testing.T, fsys afero.Fs, analytics *fakeAnalytics) *Service { + t.Helper() + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return time.Date(2026, time.April, 15, 12, 0, 0, 0, time.UTC) }, + }) + require.NoError(t, err) + return service +} + +func TestHasLinkedProject(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + + t.Run("false when no cache", func(t *testing.T) { + fsys := afero.NewMemMapFs() + assert.False(t, HasLinkedProject(fsys)) + }) + + t.Run("true when cache exists", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveLinkedProject(testProject, fsys)) + assert.True(t, HasLinkedProject(fsys)) + }) +} + +func TestCacheProjectAndIdentifyGroups(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + + t.Run("writes cache file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + CacheProjectAndIdentifyGroups(testProject, nil, fsys) + + linked, err := LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, "proj_abc", linked.Ref) + assert.Equal(t, "org_123", linked.OrganizationID) + assert.Equal(t, "acme", linked.OrganizationSlug) + assert.Equal(t, "My Project", linked.Name) + }) + + t.Run("fires GroupIdentify for org and project", func(t *testing.T) { + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + service := newTestService(t, fsys, analytics) + + CacheProjectAndIdentifyGroups(testProject, service, fsys) + + require.Len(t, analytics.groupIdentifies, 2) + + orgCall := analytics.groupIdentifies[0] + assert.Equal(t, GroupOrganization, orgCall.groupType) + assert.Equal(t, "org_123", orgCall.groupKey) + assert.Equal(t, "acme", orgCall.properties["organization_slug"]) + + projCall := analytics.groupIdentifies[1] + assert.Equal(t, GroupProject, projCall.groupType) + assert.Equal(t, "proj_abc", projCall.groupKey) + assert.Equal(t, "My Project", projCall.properties["name"]) + assert.Equal(t, "acme", projCall.properties["organization_slug"]) + }) + + t.Run("skips GroupIdentify when service is nil", func(t *testing.T) { + fsys := afero.NewMemMapFs() + CacheProjectAndIdentifyGroups(testProject, nil, fsys) + + // Cache should still be written + linked, err := LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, "proj_abc", linked.Ref) + }) + + t.Run("skips GroupIdentify for empty org ID", func(t *testing.T) { + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + service := newTestService(t, fsys, analytics) + + noOrgProject := api.V1ProjectWithDatabaseResponse{ + Ref: "proj_abc", + Name: "My Project", + } + CacheProjectAndIdentifyGroups(noOrgProject, service, fsys) + + // Only project GroupIdentify, no org + require.Len(t, analytics.groupIdentifies, 1) + assert.Equal(t, GroupProject, analytics.groupIdentifies[0].groupType) + }) +} + +func TestLinkedProjectGroups(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + + t.Run("returns nil when no cache", func(t *testing.T) { + fsys := afero.NewMemMapFs() + groups := linkedProjectGroups(fsys) + assert.Nil(t, groups) + }) + + t.Run("returns groups from cache", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveLinkedProject(testProject, fsys)) + groups := linkedProjectGroups(fsys) + assert.Equal(t, map[string]string{ + GroupOrganization: "org_123", + GroupProject: "proj_abc", + }, groups) + }) +} diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go index eb77eefff..637dcad9b 100644 --- a/pkg/api/types.gen.go +++ b/pkg/api/types.gen.go @@ -492,9 +492,8 @@ const ( // Defines values for JitAccessRequestRequestState. const ( - Disabled JitAccessRequestRequestState = "disabled" - Enabled JitAccessRequestRequestState = "enabled" - Unavailable JitAccessRequestRequestState = "unavailable" + Disabled JitAccessRequestRequestState = "disabled" + Enabled JitAccessRequestRequestState = "enabled" ) // Defines values for ListActionRunResponseRunStepsName. @@ -1418,6 +1417,7 @@ const ( V1ListEntitlementsResponseEntitlementsFeatureKeyReplicationEtl V1ListEntitlementsResponseEntitlementsFeatureKey = "replication.etl" V1ListEntitlementsResponseEntitlementsFeatureKeySecurityAuditLogsDays V1ListEntitlementsResponseEntitlementsFeatureKey = "security.audit_logs_days" V1ListEntitlementsResponseEntitlementsFeatureKeySecurityEnforceMfa V1ListEntitlementsResponseEntitlementsFeatureKey = "security.enforce_mfa" + V1ListEntitlementsResponseEntitlementsFeatureKeySecurityIso27001Certificate V1ListEntitlementsResponseEntitlementsFeatureKey = "security.iso27001_certificate" V1ListEntitlementsResponseEntitlementsFeatureKeySecurityMemberRoles V1ListEntitlementsResponseEntitlementsFeatureKey = "security.member_roles" V1ListEntitlementsResponseEntitlementsFeatureKeySecurityPrivateLink V1ListEntitlementsResponseEntitlementsFeatureKey = "security.private_link" V1ListEntitlementsResponseEntitlementsFeatureKeySecurityQuestionnaire V1ListEntitlementsResponseEntitlementsFeatureKey = "security.questionnaire" diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 82c708e37..c1795c897 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -163,6 +163,7 @@ type ( SigningKeysPath string `toml:"signing_keys_path" json:"signing_keys_path"` SigningKeys []JWK `toml:"-" json:"-"` Passkey *passkey `toml:"passkey" json:"passkey"` + Webauthn *webauthn `toml:"webauthn" json:"webauthn"` RateLimit rateLimit `toml:"rate_limit" json:"rate_limit"` Captcha *captcha `toml:"captcha" json:"captcha"` @@ -380,7 +381,10 @@ type ( } passkey struct { - Enabled bool `toml:"enabled" json:"enabled"` + Enabled bool `toml:"enabled" json:"enabled"` + } + + webauthn struct { RpDisplayName string `toml:"rp_display_name" json:"rp_display_name"` RpId string `toml:"rp_id" json:"rp_id"` RpOrigins []string `toml:"rp_origins" json:"rp_origins"` @@ -418,6 +422,9 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody { if a.Passkey != nil { a.Passkey.toAuthConfigBody(&body) } + if a.Webauthn != nil { + a.Webauthn.toAuthConfigBody(&body) + } a.Hook.toAuthConfigBody(&body) a.MFA.toAuthConfigBody(&body) a.Sessions.toAuthConfigBody(&body) @@ -442,6 +449,7 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) { prc := ValOrDefault(remoteConfig.PasswordRequiredCharacters, "") a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc)) a.Passkey.fromAuthConfig(remoteConfig) + a.Webauthn.fromAuthConfig(remoteConfig) a.RateLimit.fromAuthConfig(remoteConfig) if s := a.Email.Smtp; s != nil && s.Enabled { a.RateLimit.EmailSent = cast.IntToUint(ValOrDefault(remoteConfig.RateLimitEmailSent, 0)) @@ -502,11 +510,7 @@ func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { } func (p passkey) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - if body.PasskeyEnabled = cast.Ptr(p.Enabled); p.Enabled { - body.WebauthnRpDisplayName = nullable.NewNullableWithValue(p.RpDisplayName) - body.WebauthnRpId = nullable.NewNullableWithValue(p.RpId) - body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(p.RpOrigins, ",")) - } + body.PasskeyEnabled = cast.Ptr(p.Enabled) } func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { @@ -514,15 +518,25 @@ func (p *passkey) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { if p == nil { return } - // Ignore disabled passkey fields to minimise config diff - if p.Enabled { - p.RpDisplayName = ValOrDefault(remoteConfig.WebauthnRpDisplayName, "") - p.RpId = ValOrDefault(remoteConfig.WebauthnRpId, "") - p.RpOrigins = strToArr(ValOrDefault(remoteConfig.WebauthnRpOrigins, "")) - } p.Enabled = remoteConfig.PasskeyEnabled } +func (w webauthn) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { + body.WebauthnRpDisplayName = nullable.NewNullableWithValue(w.RpDisplayName) + body.WebauthnRpId = nullable.NewNullableWithValue(w.RpId) + body.WebauthnRpOrigins = nullable.NewNullableWithValue(strings.Join(w.RpOrigins, ",")) +} + +func (w *webauthn) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { + // When local config is not set, we assume platform defaults should not change + if w == nil { + return + } + w.RpDisplayName = ValOrDefault(remoteConfig.WebauthnRpDisplayName, "") + w.RpId = ValOrDefault(remoteConfig.WebauthnRpId, "") + w.RpOrigins = strToArr(ValOrDefault(remoteConfig.WebauthnRpOrigins, "")) +} + func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { // When local config is not set, we assume platform defaults should not change if hook := h.BeforeUserCreated; hook != nil { diff --git a/pkg/config/auth_test.go b/pkg/config/auth_test.go index 65f0066da..61ba5b429 100644 --- a/pkg/config/auth_test.go +++ b/pkg/config/auth_test.go @@ -215,8 +215,8 @@ func TestCaptchaDiff(t *testing.T) { func TestPasskeyConfigMapping(t *testing.T) { t.Run("serializes passkey config to update body", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{ - Enabled: true, + c.Passkey = &passkey{Enabled: true} + c.Webauthn = &webauthn{ RpDisplayName: "Supabase CLI", RpId: "localhost", RpOrigins: []string{ @@ -235,14 +235,9 @@ func TestPasskeyConfigMapping(t *testing.T) { assert.Equal(t, "http://127.0.0.1:3000,https://localhost:3000", ValOrDefault(body.WebauthnRpOrigins, "")) }) - t.Run("does not serialize rp fields when passkey is disabled", func(t *testing.T) { + t.Run("does not serialize rp fields when webauthn is undefined", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{ - Enabled: false, - RpDisplayName: "Supabase CLI", - RpId: "localhost", - RpOrigins: []string{"http://127.0.0.1:3000"}, - } + c.Passkey = &passkey{Enabled: false} // Run test body := c.ToUpdateAuthConfigBody() // Check result @@ -257,12 +252,27 @@ func TestPasskeyConfigMapping(t *testing.T) { assert.Error(t, err) }) - t.Run("hydrates passkey config from remote", func(t *testing.T) { + t.Run("serializes webauthn fields independently of passkey", func(t *testing.T) { c := newWithDefaults() - c.Passkey = &passkey{ - Enabled: true, + c.Webauthn = &webauthn{ + RpDisplayName: "Supabase CLI", + RpId: "localhost", + RpOrigins: []string{"http://127.0.0.1:3000"}, } // Run test + body := c.ToUpdateAuthConfigBody() + // Check result + assert.Nil(t, body.PasskeyEnabled) + assert.Equal(t, "Supabase CLI", ValOrDefault(body.WebauthnRpDisplayName, "")) + assert.Equal(t, "localhost", ValOrDefault(body.WebauthnRpId, "")) + assert.Equal(t, "http://127.0.0.1:3000", ValOrDefault(body.WebauthnRpOrigins, "")) + }) + + t.Run("hydrates passkey and webauthn config from remote", func(t *testing.T) { + c := newWithDefaults() + c.Passkey = &passkey{Enabled: true} + c.Webauthn = &webauthn{} + // Run test c.FromRemoteAuthConfig(v1API.AuthConfigResponse{ PasskeyEnabled: true, WebauthnRpDisplayName: nullable.NewNullableWithValue("Supabase CLI"), @@ -272,12 +282,14 @@ func TestPasskeyConfigMapping(t *testing.T) { // Check result if assert.NotNil(t, c.Passkey) { assert.True(t, c.Passkey.Enabled) - assert.Equal(t, "Supabase CLI", c.Passkey.RpDisplayName) - assert.Equal(t, "localhost", c.Passkey.RpId) + } + if assert.NotNil(t, c.Webauthn) { + assert.Equal(t, "Supabase CLI", c.Webauthn.RpDisplayName) + assert.Equal(t, "localhost", c.Webauthn.RpId) assert.Equal(t, []string{ "http://127.0.0.1:3000", "https://localhost:3000", - }, c.Passkey.RpOrigins) + }, c.Webauthn.RpOrigins) } }) @@ -292,6 +304,7 @@ func TestPasskeyConfigMapping(t *testing.T) { }) // Check result assert.Nil(t, c.Passkey) + assert.Nil(t, c.Webauthn) }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 90d81741b..d17b89441 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -262,9 +262,13 @@ func (a *auth) Clone() auth { } if copy.Passkey != nil { passkey := *a.Passkey - passkey.RpOrigins = slices.Clone(a.Passkey.RpOrigins) copy.Passkey = &passkey } + if copy.Webauthn != nil { + webauthn := *a.Webauthn + webauthn.RpOrigins = slices.Clone(a.Webauthn.RpOrigins) + copy.Webauthn = &webauthn + } copy.External = maps.Clone(a.External) if a.Email.Smtp != nil { mailer := *a.Email.Smtp @@ -656,10 +660,7 @@ func (c *config) Load(path string, fsys fs.FS, overrides ...ConfigEditor) error } } if version, err := fs.ReadFile(fsys, builder.StorageVersionPath); err == nil && len(version) > 0 { - // Only replace image if local storage version is newer - if i := strings.IndexByte(Images.Storage, ':'); semver.Compare(strings.TrimSpace(string(version)), Images.Storage[i+1:]) > 0 { - c.Storage.Image = replaceImageTag(Images.Storage, string(version)) - } + c.Storage.Image = replaceImageTag(Images.Storage, string(version)) } if version, err := fs.ReadFile(fsys, builder.StorageMigrationPath); err == nil && len(version) > 0 { c.Storage.TargetMigration = strings.TrimSpace(string(version)) @@ -921,21 +922,22 @@ func (c *config) Validate(fsys fs.FS) error { return errors.Errorf("failed to decode signing keys: %w", err) } } - if c.Auth.Passkey != nil { - if c.Auth.Passkey.Enabled { - if len(c.Auth.Passkey.RpId) == 0 { - return errors.New("Missing required field in config: auth.passkey.rp_id") - } - if len(c.Auth.Passkey.RpOrigins) == 0 { - return errors.New("Missing required field in config: auth.passkey.rp_origins") - } - if err := assertEnvLoaded(c.Auth.Passkey.RpId); err != nil { - return errors.Errorf("Invalid config for auth.passkey.rp_id: %v", err) - } - for i, origin := range c.Auth.Passkey.RpOrigins { - if err := assertEnvLoaded(origin); err != nil { - return errors.Errorf("Invalid config for auth.passkey.rp_origins[%d]: %v", i, err) - } + if c.Auth.Passkey != nil && c.Auth.Passkey.Enabled { + if c.Auth.Webauthn == nil { + return errors.New("Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)") + } + if len(c.Auth.Webauthn.RpId) == 0 { + return errors.New("Missing required field in config: auth.webauthn.rp_id") + } + if len(c.Auth.Webauthn.RpOrigins) == 0 { + return errors.New("Missing required field in config: auth.webauthn.rp_origins") + } + if err := assertEnvLoaded(c.Auth.Webauthn.RpId); err != nil { + return errors.Errorf("Invalid config for auth.webauthn.rp_id: %v", err) + } + for i, origin := range c.Auth.Webauthn.RpOrigins { + if err := assertEnvLoaded(origin); err != nil { + return errors.Errorf("Invalid config for auth.webauthn.rp_origins[%d]: %v", i, err) } } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 695733116..f019b7cbc 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -74,7 +74,7 @@ func TestConfigParsing(t *testing.T) { // Run test assert.Error(t, config.Load("", fsys)) }) - t.Run("config file with passkey settings", func(t *testing.T) { + t.Run("config file with passkey and webauthn settings", func(t *testing.T) { config := NewConfig() fsys := fs.MapFS{ "supabase/config.toml": &fs.MapFile{Data: []byte(` @@ -83,6 +83,7 @@ enabled = true site_url = "http://127.0.0.1:3000" [auth.passkey] enabled = true +[auth.webauthn] rp_display_name = "Supabase CLI" rp_id = "localhost" rp_origins = ["http://127.0.0.1:3000", "https://localhost:3000"] @@ -93,15 +94,56 @@ rp_origins = ["http://127.0.0.1:3000", "https://localhost:3000"] // Check result if assert.NotNil(t, config.Auth.Passkey) { assert.True(t, config.Auth.Passkey.Enabled) - assert.Equal(t, "Supabase CLI", config.Auth.Passkey.RpDisplayName) - assert.Equal(t, "localhost", config.Auth.Passkey.RpId) + } + if assert.NotNil(t, config.Auth.Webauthn) { + assert.Equal(t, "Supabase CLI", config.Auth.Webauthn.RpDisplayName) + assert.Equal(t, "localhost", config.Auth.Webauthn.RpId) assert.Equal(t, []string{ "http://127.0.0.1:3000", "https://localhost:3000", - }, config.Auth.Passkey.RpOrigins) + }, config.Auth.Webauthn.RpOrigins) } }) + t.Run("webauthn section without passkey loads successfully", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[auth] +enabled = true +site_url = "http://127.0.0.1:3000" +[auth.webauthn] +rp_display_name = "Supabase CLI" +rp_id = "localhost" +rp_origins = ["http://127.0.0.1:3000"] +`)}, + } + // Run test + assert.NoError(t, config.Load("", fsys)) + // Check result + assert.Nil(t, config.Auth.Passkey) + if assert.NotNil(t, config.Auth.Webauthn) { + assert.Equal(t, "localhost", config.Auth.Webauthn.RpId) + } + }) + + t.Run("passkey enabled requires webauthn section", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[auth] +enabled = true +site_url = "http://127.0.0.1:3000" +[auth.passkey] +enabled = true +`)}, + } + // Run test + err := config.Load("", fsys) + // Check result + assert.ErrorContains(t, err, "Missing required config section: auth.webauthn") + }) + t.Run("passkey enabled requires rp_id", func(t *testing.T) { config := NewConfig() fsys := fs.MapFS{ @@ -111,13 +153,14 @@ enabled = true site_url = "http://127.0.0.1:3000" [auth.passkey] enabled = true +[auth.webauthn] rp_origins = ["http://127.0.0.1:3000"] `)}, } // Run test err := config.Load("", fsys) // Check result - assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_id") + assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_id") }) t.Run("passkey enabled requires rp_origins", func(t *testing.T) { @@ -129,13 +172,14 @@ enabled = true site_url = "http://127.0.0.1:3000" [auth.passkey] enabled = true +[auth.webauthn] rp_id = "localhost" `)}, } // Run test err := config.Load("", fsys) // Check result - assert.ErrorContains(t, err, "Missing required field in config: auth.passkey.rp_origins") + assert.ErrorContains(t, err, "Missing required field in config: auth.webauthn.rp_origins") }) t.Run("parses experimental pgdelta config", func(t *testing.T) { diff --git a/pkg/config/templates/Dockerfile b/pkg/config/templates/Dockerfile index 64cb0639c..ad5da51e2 100644 --- a/pkg/config/templates/Dockerfile +++ b/pkg/config/templates/Dockerfile @@ -3,17 +3,17 @@ FROM supabase/postgres:17.6.1.106 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit -FROM postgrest/postgrest:v14.8 AS postgrest +FROM postgrest/postgrest:v14.10 AS postgrest FROM supabase/postgres-meta:v0.96.4 AS pgmeta -FROM supabase/studio:2026.04.08-sha-205cbe7 AS studio +FROM supabase/studio:2026.04.20-sha-b721a2d AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy -FROM supabase/edge-runtime:v1.73.3 AS edgeruntime +FROM supabase/edge-runtime:v1.73.13 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.7.4 AS supavisor FROM supabase/gotrue:v2.188.1 AS gotrue -FROM supabase/realtime:v2.82.0 AS realtime -FROM supabase/storage-api:v1.48.28 AS storage -FROM supabase/logflare:1.37.1 AS logflare +FROM supabase/realtime:v2.86.3 AS realtime +FROM supabase/storage-api:v1.54.1 AS storage +FROM supabase/logflare:1.39.1 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index 2909f8223..97ed4e566 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -180,6 +180,9 @@ password_requirements = "" # Configure passkey sign-ins. # [auth.passkey] # enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] # rp_display_name = "Supabase" # rp_id = "localhost" # rp_origins = ["http://127.0.0.1:3000"] diff --git a/pkg/migration/dump.go b/pkg/migration/dump.go index 2f87cb319..0a1d6cdb3 100644 --- a/pkg/migration/dump.go +++ b/pkg/migration/dump.go @@ -26,6 +26,7 @@ var ( "_realtime", "_supavisor", "auth", + "etl", "extensions", "pgbouncer", "realtime", @@ -72,6 +73,7 @@ var ( "vault", // Managed by Supabase // "auth", + "etl", "extensions", "pgbouncer", "realtime", diff --git a/pkg/parser/state.go b/pkg/parser/state.go index f32a67131..47775390d 100644 --- a/pkg/parser/state.go +++ b/pkg/parser/state.go @@ -46,14 +46,40 @@ func (s *ReadyState) Next(r rune, data []byte) State { case 'c': fallthrough case 'C': - offset := len(data) - len(BEGIN_ATOMIC) - if offset >= 0 && strings.EqualFold(string(data[offset:]), BEGIN_ATOMIC) { + if isBeginAtomic(data) { return &AtomicState{prev: s, delimiter: []byte(END_ATOMIC)} } } return s } +func isBeginAtomic(data []byte) bool { + offset := len(data) - len(BEGIN_ATOMIC) + if offset < 0 || !strings.EqualFold(string(data[offset:]), BEGIN_ATOMIC) { + return false + } + if offset > 0 { + r, _ := utf8.DecodeLastRune(data[:offset]) + if isIdentifierRune(r) { + return false + } + } + prefix := bytes.TrimRightFunc(data[:offset], unicode.IsSpace) + offset = len(prefix) - len("BEGIN") + if offset < 0 || !strings.EqualFold(string(prefix[offset:]), "BEGIN") { + return false + } + if offset == 0 { + return true + } + r, _ := utf8.DecodeLastRune(prefix[:offset]) + return !isIdentifierRune(r) +} + +func isIdentifierRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '$' +} + // Opened a line comment type CommentState struct{} diff --git a/pkg/parser/state_test.go b/pkg/parser/state_test.go index bae10fe19..ad6db9d26 100644 --- a/pkg/parser/state_test.go +++ b/pkg/parser/state_test.go @@ -167,4 +167,44 @@ END ;`} checkSplit(t, sql) }) + + t.Run("ignores atomic in identifiers", func(t *testing.T) { + names := []string{ + "fn_atomic", + "atomic_fn", + "my_atomic_thing", + "xatomicx", + "fn_ATomiC", + } + for _, name := range names { + t.Run(name, func(t *testing.T) { + sql := []string{ + `CREATE OR REPLACE FUNCTION ` + name + `() +RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + NULL; +END; +$$;`, + ` +SELECT 1;`, + } + checkSplit(t, sql) + }) + } + }) + + t.Run("does not treat schema-qualified atomic function names as begin atomic", func(t *testing.T) { + sql := []string{`CREATE OR REPLACE FUNCTION public.atomic_example() +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN 1; +END; +$$;`, + ` +GRANT EXECUTE ON FUNCTION public.atomic_example() TO authenticated;`, + } + checkSplit(t, sql) + }) }